I wanted to stream one of my Ubiquiti cameras without exposing the rtsps stream directly to the internet. They do offer a “public” URL option directly with the camera, but that is limited to one stream/watcher at a time, which is fine for a one-off or very specific scenario. But I wanted to be able to stream one all the time, regarless of how many users want to view it, at least up to the bandwidth I have. I also wanted it to use SSL (or equivalent). Since I already have an SSL enable apache server, I could just use that as the engine to feed the streams.
Rather than using rtsps, I decided to use something that can be done in-browser without any plugins or special programs, and would allow me to use apache. It turns out, ffmpeg has the ability to do just that. It can create an HLS stream from an rtsp stream. As it happens, the Unifi cameras already encode to hevc, and have both AAC and Opus audio available. The stream order was a bit odd, with the audio streams first, and the video after. But once you know which streams are which, you can use the ffmpeg -map option to get them ordered correctly. After a lot of trial and error, and figuring out all the options, this is the command that ultimately worked for me:
ffmpeg \
-loglevel error \
-hide_banner \
-nostats \
-v quiet \
-protocol_whitelist file,http,https,tcp,tls,rtp,crypto \
-rtsp_transport tcp \
-probesize 10M -analyzeduration 5000000 \
-use_wallclock_as_timestamps 1 \
-fflags +genpts \
-i "rtsps://protect-server:7441/XXXXXXXXXXXXXXXX?enableSrtp" \
-c:v copy \
-c:a copy \
-map 0:2 -map 0:0 \
-f hls \
-hls_time 2 \
-hls_list_size 6 \
-hls_flags delete_segments+omit_endlist \
-hls_segment_type fmp4 \
-hls_init_time 2 \
-strftime 1 \
-hls_segment_filename "/var/www/html/camera/cam-%Y-%m-%d_%H-%M-%S.mp4" \
-hls_fmp4_init_filename "init.mp4" \
"/var/www/html/camera/index.m3u8"
This can be part of a startup script that can be made into a systemd service easily enough.
I created /etc/systemd/system/camera.service and put in the following:
[Unit]
Description=Camera RTSPS → HLS (HEVC pass-through + AAC audio)
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/html/camera
ExecStart=/bin/bash /usr/local/bin/camera
Restart=on-failure
RestartSec=5
StandardOutput=append:/var/log/camera.log
StandardError=append:/var/log/camera.err
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/www/html/camera /var/log
[Install]
WantedBy=multi-user.target
And then in /usr/local/bin/camera, I put:
#!/bin/bash
set -euo pipefail
# The CAMERA_URL can be retrieved from Unifi Protect under the specific camera's settings
# Choose the resolution you want to stream, and use that URL here.
CAMERA_URL="rtsps://protect-server:7441/XXXXXXXXX?enableSrtp"
# Put this where ever you want the files to be created and served from
OUTPUT_DIR="/var/www/html/camera"
# Ensure directory exists & owned by www-data
mkdir -p "$OUTPUT_DIR"
chown www-data:www-data "$OUTPUT_DIR" 2>/dev/null || true
echo "$(date '+%Y-%m-%d %H:%M:%S') Starting HEVC HLS → ${OUTPUT_DIR}"
while true; do
ffmpeg \
-loglevel error \
-hide_banner \
-nostats \
-v quiet \
-protocol_whitelist file,http,https,tcp,tls,rtp,crypto \
-rtsp_transport tcp \
-probesize 10M -analyzeduration 5000000 \
-use_wallclock_as_timestamps 1 \
-fflags +genpts \
-i "$CAMERA_URL" \
-c:v copy \
-c:a copy \
-map 0:2 -map 0:0 \
-f hls \
-hls_time 2 \
-hls_list_size 6 \
-hls_flags delete_segments+omit_endlist \
-hls_segment_type fmp4 \
-hls_init_time 2 \
-strftime 1 \
-hls_segment_filename "${OUTPUT_DIR}/camera-%Y-%m-%d_%H-%M-%S.mp4" \
-hls_fmp4_init_filename "init.mp4" \
"${OUTPUT_DIR}/index.m3u8"
echo "$(date '+%Y-%m-%d %H:%M:%S') FFmpeg exited. Restarting in 3s..."
sleep 3
done
Do a systemctl daemon-reload to load in the service you created. Then systemctl start camera to get the process running. You could transcode to other formats with ffmpeg rather than using the copy option as I am, but that adds a lot more work to the system doing the conversion. Unless you have a need (like older browsers, or OSes that don’t support H.265/HEVC), just pass the stream directly.
For my apache conf, I added the following to my site conf:
Alias "/camera" "/var/www/html/camera"
<Directory "/var/www/html/camera">
Require all granted
Options -Indexes +FollowSymLinks
# Critical MIME types for HLS/HEVC
AddType application/vnd.apple.mpegurl .m3u8
AddType video/mp2t .ts # (not needed, but safe)
AddType audio/aac .aac # (no-op, since we're using fmp4)
AddType video/mp4 .mp4
Header set Cache-Control "no-cache"
Header set Access-Control-Allow-Origin "*"
</Directory>
# Serve HLS segments transparently (no trailing slash confusion)
RewriteRule "^/camera$" "/camera/" [R=301,L]
Modern browsers should work if you just point to the index.m3u8, but you end up with a rather ugly presentation. I found some lightweight javascript out on the net to make it look a little prettier. I pull it directly from the internet in this example, but I would highly recommend you keep a local copy and server that to your users. That way if the source is unresponsive, your page will still work. The information I used to make this was a little out of date. At the time it was discussed, Safari was the only browser that supported HLS with HEVC video. I think that is no longer the case, but I am not sure as I do not use Chrome. As written, this works for me in Safari and in Firefox. I will update this block once I’ve done more testing and tuning of other browsers.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Camera — Live Feed</title>
<style>
body { background:#000; margin:0; color:#fff; font-family:sans-serif }
#video-container { max-width:1280px; margin:40px auto }
video { width:100%; border-radius:6px; box-shadow:0 0 20px rgba(0,0,0,0.5) }
</style>
</head>
<body>
<div id="video-container">
<h2 style="text-align:center; margin-bottom:24px;">CritterCam (4K HEVC)</h2>
<video id="player" controls autoplay muted playsinline></video>
</div>
<!-- Native HLS support for Safari/iOS (HEVC) -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
const video = document.getElementById('player');
const hlsUrl = 'https://www.yourserver.com/camera/index.m3u8';
// Try native HLS first (Safari, iOS)
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = hlsUrl;
video.addEventListener('loadedmetadata', () => video.play());
}
// Otherwise use hls.js for Chrome/Firefox
else if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
} else {
video.innerHTML = '<p>HEVC HLS requires Safari/iOS 15+ or Chrome with HEVC extension.</p>';
}
</script>
</body>
</html>