Building a Self-Hosted NVR Web Gallery with Live Streaming
How I built a self-hosted NVR web interface with live WebRTC streaming, a recording gallery browser, and on-device storage — using Reolink cameras, go2rtc, vsftpd, and nginx on my Arch Linux homelab.
I've been running a couple of Reolink RLC-520A cameras around my house for a while now, and while the Reolink app is fine for casual use, I wanted something more: a self-hosted web interface I could pull up on any browser on my local network, browse recordings by date, watch clips without downloading anything, and see live feeds from both cameras in one place. This post walks through how I built it from scratch on my Arch Linux homelab server.
The Stack
Everything runs on my main server — a Ryzen 5 8600G with 16GB RAM. The full stack ended up being:
- vsftpd — FTP server that receives recordings directly from the cameras
- go2rtc — RTSP-to-WebRTC bridge for live streaming
- nginx — serves the frontend and proxies the go2rtc WebSocket
- Docker Compose — ties nginx and go2rtc together
- A single-file vanilla JS/HTML frontend — no frameworks, no build step
Step 1: FTP Server for Camera Uploads
The Reolink cameras support FTP upload for recordings and snapshots. I set up vsftpd on the server with anonymous upload enabled, pointed at /disks/nvme2tb/NVR/ with subdirectories for each camera.
Getting vsftpd to cooperate with the Reolink firmware took more debugging than expected. The cameras insist on attempting AUTH TLS before every connection, which caused vsftpd to respond with a 530 error and leave the camera stuck in a login loop. The fix was enabling SSL properly with a self-signed cert and setting force_anon_logins_ssl=NO and force_anon_data_ssl=NO — letting TLS negotiate without requiring it.
The other gotcha was vsftpd's chroot security check. The anon_root directory can't be writable, so the solution was to make /disks/nvme2tb/NVR owned by root with 755 permissions, while the actual camera subdirectories (Front/ and Rear/) are owned by the ftp user and fully writable.
The final vsftpd config that works:
anonymous_enable=YES
no_anon_password=YES
anon_root=/disks/nvme2tb/NVR
anon_upload_enable=YES
anon_mkdir_write_enable=YES
anon_other_write_enable=YES
write_enable=YES
local_enable=NO
ssl_enable=YES
allow_anon_ssl=YES
force_anon_logins_ssl=NO
force_anon_data_ssl=NO
require_ssl_reuse=NO
pasv_enable=YES
pasv_min_port=40000
pasv_max_port=40100
pasv_address=192.168.50.61
seccomp_sandbox=NO
isolate_network=NO
pam_service_name=vsftpd
The cameras auto-organize uploads into Front/YYYY/MM/DD/ and Rear/YYYY/MM/DD/ directories, which made building the date browser in the frontend trivial.
Step 2: The Frontend Gallery
Rather than reaching for a framework, I wrote the entire frontend as a single index.html served by nginx. It fetches nginx's JSON autoindex output to walk the directory tree and build the file list entirely client-side.
Key features:
Recordings browser — files grouped by time of day (Morning / Afternoon / Evening / Night), paginated at 48 per page with options up to "All". Each card shows the camera name, file size, date, and time in 12-hour format extracted from the filename.
Video thumbnails — generated client-side using a hidden <video> element and <canvas>. An IntersectionObserver queues thumbnail generation only as cards scroll into view, with a concurrency limit of 3 simultaneous extractions to avoid overwhelming the browser. Generated thumbnails are cached in memory so switching filters and pages doesn't re-generate them.
Bookmarks — a ☆ icon on each card that fills to ★ when clicked. Bookmarks persist in localStorage and a dedicated filter button shows only saved items.
Today summary — when viewing all cameras with no filters, a summary bar at the top shows today's file counts, video/image breakdown, and total storage consumed.
Modal viewer — click any card to open it fullscreen with keyboard navigation (← / → arrows), a download button, and prev/next controls.
Step 3: Live Streaming with go2rtc
The cameras expose RTSP streams at port 554. Browsers can't consume RTSP directly, so I added go2rtc to the Docker Compose stack. It pulls the RTSP stream from each camera and serves it over WebRTC via a WebSocket signaling channel.
The cameras shoot H.265 at 2560×1920 / 25fps / 8192 Kbps. Firefox doesn't support H.265 in WebRTC at all, and Chrome on some platforms is hit-or-miss, so I configured go2rtc to transcode to H.264 using the Ryzen's integrated GPU via VAAPI:
streams:
front:
- rtsp://admin:[email protected]:554/h264Preview_01_main
- ffmpeg:front#video=h264#hardware=vaapi#audio=opus
rear:
- rtsp://admin:[email protected]:554/h264Preview_01_main
- ffmpeg:rear#video=h264#hardware=vaapi#audio=opus
ffmpeg:
h264: "-codec:v h264_vaapi -g:v 25 -keyint_min:v 25 -force_key_frames:v expr:gte(t\\,n_forced*1) -preset:v fast -profile:v baseline -level:v 3.1"
The forced keyframe every second (-g:v 25 at 25fps) was critical — without it, new WebRTC connections would often render a grey corrupted frame for several seconds while waiting for the camera's natural IDR keyframe interval.
The WebRTC signaling in the frontend connects directly to go2rtc's WebSocket API at /api/ws?src=front&video=h264&audio=opus, explicitly requesting the H.264 track:
const ws = new WebSocket(`ws://${location.host}/api/ws?src=${cam}&video=h264&audio=opus`);
ws.onopen = async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ type: 'webrtc/offer', value: offer.sdp }));
};
ws.onmessage = async e => {
const msg = JSON.parse(e.data);
if (msg.type === 'webrtc/answer')
await pc.setRemoteDescription({ type: 'answer', sdp: msg.value });
else if (msg.type === 'webrtc/candidate')
await pc.addIceCandidate({ candidate: msg.value, sdpMid: '0' });
};
Streams auto-reconnect on disconnect, show a live clock timestamp overlay, and have a fullscreen button.
Step 4: Stats and Cleanup
The Stats tab shows total file counts, per-camera storage breakdown, and a 30-day bar chart of recordings per day. Clicking any bar drills into that day's detail — files by type, size, camera, and time-of-day distribution.
For storage management I wrote a simple cleanup script that removes mp4 files older than 7 days from both camera directories and cleans up empty date folders:
mapfile -t FILES < <(find "$CAM_DIR" -type f -name "*.mp4" -mtime +7)
for f in "${FILES[@]}"; do
SZ=$(stat -c%s "$f")
TOTAL_BYTES=$((TOTAL_BYTES + SZ))
rm -f "$f"
done
find "$CAM_DIR" -mindepth 1 -type d -empty -delete
Calculating sizes before deletion (not after) was the key detail — du on already-deleted files returns zero.
Docker Compose
The full stack is two containers:
services:
go2rtc:
image: alexxit/go2rtc:latest-hardware
container_name: nvr-go2rtc
restart: unless-stopped
network_mode: host
volumes:
- ./go2rtc.yaml:/config/go2rtc.yaml:ro
nvr-gallery:
image: nginx:alpine
container_name: nvr-gallery
ports:
- "8889:80"
volumes:
- /disks/nvme2tb/NVR:/data/NVR:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./gallery:/usr/share/nginx/html:ro
depends_on:
- go2rtc
go2rtc runs in network_mode: host so it can reach the cameras on the LAN directly and bind to the WebRTC UDP ports without NAT complications. nginx handles the gallery frontend and proxies go2rtc's API for the WebSocket signaling.
Lessons Learned
vsftpd's AUTH TLS handling — The Reolink firmware always attempts TLS negotiation. Disabling SSL entirely causes 530 Please login with USER and PASS in response to AUTH TLS, which confuses the camera firmware into an infinite reconnect loop. You have to actually enable SSL and let the handshake complete.
go2rtc signaling — The go2rtc API uses WebSocket-based signaling, not HTTP POST like older documentation suggests. The correct endpoint is /api/ws?src=STREAM_NAME with JSON messages of type webrtc/offer, webrtc/answer, and webrtc/candidate.
H.265 in browsers — Firefox doesn't support H.265 in WebRTC at all. Even in Chrome, receiving H.265 without a proper IDR keyframe produces a grey corrupted image. Transcoding to H.264 with forced keyframes solved both problems cleanly.
nftables policy drop — My server's default nftables config drops everything not explicitly allowed. FTP passive mode needs both port 21 and the PASV range (40000-40100) open, and go2rtc needs ports 1984, 8554, and 8555. Easy to miss if you're used to permissive default firewall configs.
The end result is a clean, fast, fully local NVR web interface that works on every browser and device on my network. No cloud, no subscriptions, no data leaving the house. The full project is on GitHub: TechieAndroid/nvr.