treeru.com
Network

Build a CCTV Recording Server Without an NVR — RTSP + ffmpeg + systemd, 180-Day Retention

2026-06-12
Treeru
Network

There are two CCTV cameras in our server room. For security reasons they live on a network segment isolated from both the office and server networks, and they are never exposed to the internet. The usual answer for cameras like these is to buy an NVR (a dedicated recording appliance) or pay for the vendor’s cloud subscription. We do neither, because we already have a Linux backup server running 24/7. Once you pull the camera’s RTSP stream with ffmpeg and write it straight to disk, you have covered essentially everything an NVR does.

Close-up of a dome CCTV camera's glass lens — water droplets cling to the surface while blue and yellow city night lights reflect as bokeh
Raindrops on a dome camera lens catch the blurred blue and yellow lights of the night city — writing down what the lens watches is what this guide is about

0%

Re-encoding CPU load (-c copy)

96

Segments per day (15-minute chunks)

180 days

Retention (about 0.9TB)

2K

2880×1620 HEVC, untouched

This post walks through that setup end to end. The short version: with no re-encoding at all (-c copy), CPU load stays near 0% while the server writes 96 fifteen-minute segments per day with no gaps. Recording resumes automatically after a reboot, and footage older than 180 days is purged every night without anyone touching it.

Why We Skipped the NVR

Home and small-office CCTV storage usually comes down to one of three options: an SD card in the camera, a vendor cloud subscription, or an NVR appliance. Each has a clear ceiling.

OptionLimitation
SD cardIf the camera is stolen or destroyed, the evidence goes with it. Capacity caps retention to a short window, and cards wear out
Cloud subscriptionYou pay a monthly fee per camera, forever. And for some organizations, uploading footage to someone else’s server is a problem in itself
NVR applianceUp-front hardware cost, plus one more thing to maintain (firmware, disks, its own UI)

But most IP cameras can emit their video over RTSP (Real Time Streaming Protocol), a standard protocol. If a device can receive RTSP, it can be your recorder. And if you already have a Linux server running around the clock, that server is your NVR.

Our setup: of the two server-room cameras, one records to the server over RTSP (the subject of this post), while the other runs on an SD card alone. Any RTSP-capable camera works the same way regardless of vendor.

The Overall Setup

The architecture is simple. The camera sits on a wireless VLAN dedicated to IoT devices, and the recording server connects out to the camera’s RTSP port (554/tcp) to pull the stream. The incoming stream is sliced into 15-minute files and written to the backup disk with no re-encoding.

Data flow

[CCTV camera]           [recording server]           [disk]
IoT-only VLAN     ←──   ffmpeg pulls RTSP     ──→   15-minute .mkv
554/tcp (RTSP)          -c copy (no re-encode)      auto-deleted after 180 days

The key point is that the camera does not push to the server — the server pulls from the camera. That means no configuration whatsoever has to be added on the camera, and the firewall only needs a single one-way rule: “recording server → camera port 554”. You can block every outbound path from the camera and recording still works perfectly.

Network Prep — IoT Isolation and a Static IP

Two things had to be handled before recording could start: network isolation and a fixed IP.

① A dedicated IoT VLAN. IoT gear like CCTV cameras and smart plugs tends to get firmware updates on an irregular schedule, and the security track record is often poor. So they go on an IoT-only VLAN, separated from the server and office networks. The goal is simple: if an IoT device is compromised, it must not be able to reach the server network.

② A DHCP reservation.The recording server connects by IP, so if the camera picks up a new address every time it reboots, recording breaks. We reserved an IP against the camera’s MAC address in the router/firewall DHCP settings so it always gets the same address. After that, confirm the camera’s RTSP port is reachable.

# From the recording server — check the camera's RTSP port

nc -vz <camera-ip> 554
# Connection to <camera-ip> 554 port [tcp/rtsp] succeeded!

The RTSP URL format differs by manufacturer. It is usually documented in the camera’s admin UI or manual, in a form likertsp://user:password@camera-ip/stream_ch00_0.

The Core of Recording — ffmpeg Stream Copy

The entire recording script is a single ffmpeg invocation. The RTSP URL containing credentials is never hardcoded into the script — it comes in through an environment variable (kept separately, see the systemd section below).

/usr/local/sbin/cctv-record — the recording script

#!/usr/bin/env bash
set -euo pipefail
: "${RTSP_URL:?missing RTSP_URL}"
: "${OUTPUT_DIR:?missing OUTPUT_DIR}"
SEGMENT_SECONDS="${SEGMENT_SECONDS:-900}"
umask 027
mkdir -p "$OUTPUT_DIR"
exec /usr/bin/ffmpeg \
  -hide_banner \
  -loglevel warning \
  -rtsp_transport tcp \
  -fflags +genpts \
  -use_wallclock_as_timestamps 1 \
  -i "$RTSP_URL" \
  -map 0:v:0 \
  -an \
  -c copy \
  -f segment \
  -segment_time "$SEGMENT_SECONDS" \
  -reset_timestamps 1 \
  -strftime 1 \
  "$OUTPUT_DIR/%Y%m%d_%H%M%S.mkv"

Every one of those flags maps directly to operational stability.

FlagWhy it matters
-c copyThe heart of this setup. The stream the camera already encoded as HEVC goes straight into the file. With no re-encoding, CPU usage is negligible, so running this on the backup server does not disturb its actual job
-rtsp_transport tcpRTSP defaults to UDP, and over wireless links packets get mangled easily. Receiving over TCP cuts corrupted frames dramatically
-f segment + -segment_time 900Splits recording into 15-minute (900-second) files. One giant file risks losing everything if the recording is interrupted mid-write, and it makes finding a specific time range painful
-strftime 1Filenames become the recording start time, e.g. 20260612_044217.mkv. “4 AM yesterday” is findable from the filename alone
-use_wallclock_as_timestamps 1Cheap cameras produce jittery timestamps. Re-stamping against the server’s real clock reduces playback timing drift
-anDrops audio. No point storing data we do not need

When you are tempted to re-encode

You can swap -c copy for libx264/libx265 to trade quality for smaller files, but encoding 24/7 pins the CPU permanently. Lowering the bitrate in the camera’s own settings is the better first move. The server is most reliable when all it does is receive and store.

systemd Service — Surviving Reboots

Recording is not a one-shot command — it is a permanent process that must never stop. Registering it as a systemd service instead of running it under nohup or screen is what lets it recover automatically from server reboots, brief network drops, and abnormal process exits.

/etc/systemd/system/cctv-record.service

[Unit]
Description=Record CCTV RTSP stream
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=cctv
Group=cctv
EnvironmentFile=/etc/cctv/camera.env
ExecStart=/usr/local/sbin/cctv-record
Restart=always
RestartSec=10
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/mnt/storage/cctv

[Install]
WantedBy=multi-user.target

What this unit takes care of:

  • Credential separation — the URL containing the RTSP username and password lives only in/etc/cctv/camera.env, protected with root-only permissions (0600). No password ends up in the script, the unit file, or git.
  • Restart=always + RestartSec=10 — if ffmpeg dies because the camera rebooted or the Wi-Fi dropped, it reconnects automatically 10 seconds later.
  • Least privilege — it runs as a dedicated account, and NoNewPrivileges, ProtectSystem, and ReadWritePaths make writing anywhere outside the recording directory impossible. If the recording process is ever compromised, the blast radius is one folder.
  • Waiting on network-online.target — prevents ffmpeg from starting before the network is up at boot and failing immediately.

# Enable and verify

sudo systemctl daemon-reload
sudo systemctl enable --now cctv-record.service

systemctl status cctv-record.service     # active (running)
systemctl show cctv-record -p NRestarts  # check the restart count
ls -lh /mnt/storage/cctv/ | tail         # confirm files keep appearing

We rebooted the server once during production (remotely, via WoL) and recording resumed on its own right after boot. Since then all 96 daily segments have landed without a single gap, and the abnormal ffmpeg restart count (NRestarts) is 0.

Retention Policy — Automatic 180-Day Cleanup

Disks are not infinite, so old recordings have to be removed automatically. A single find command is enough for the cleanup script, and we run it every night with a systemd timer rather than cron.

/usr/local/sbin/cctv-retention — delete files past the retention window

#!/usr/bin/env bash
set -euo pipefail
OUTPUT_DIR=/mnt/storage/cctv
RETENTION_DAYS=180
find "$OUTPUT_DIR" -type f \( -name "*.mkv" -o -name "*.mp4" \) \
  -mtime +"$RETENTION_DAYS" -print -delete
find "$OUTPUT_DIR" -type d -empty -print -delete

/etc/systemd/system/cctv-retention.timer — runs daily at 03:20

[Timer]
OnCalendar=*-*-* 03:20:00
Persistent=true

[Install]
WantedBy=timers.target

Pick the retention window based on your capacity. We started at 30 days, then measured actual usage, saw how much headroom was left, and raised it to 180 days. Here is the math from the real measurements.

ItemMeasured
Resolution / codec2880×1620, HEVC (H.265) — stored exactly as the camera encodes it
One segment (15 min)about 50MB on average
Per day (96 segments)about 4.8GB
30 daysabout 145GB
180 daysabout 0.9TB

Even storing 2K footage as untouched HEVC, six months comes in under 1TB.At current disk prices, a few months of cloud subscription fees buy you years of local storage.

Security — Keeping Cameras Off the Internet

The moment an IP camera is exposed to the internet, it becomes a target. Cameras indexed by search engines and viewable by anyone are still a common occurrence. The real advantage of local recording is not cost — it is that you can take the camera off the internet entirely.

Here is how the firewall rules ended up.

DirectionPolicyReason
Recording server → camera 554/tcpAllowThe only path RTSP pull requires
Server network → IoT VLAN (everything else)BlockStops lateral movement into the server network if an IoT device is breached
Camera → internetBlock directionLocal-recording-only cameras have no need to talk outbound

A trap we actually hit — firewall rules must be verified after you write them

During a routine security review we discovered that the “server network → IoT block” rule had never worked once since the day it was created, because of a mistake in the interface it was bound to. The subtler problem: that hole was exactly why the recording traffic was getting through in the first place. Fixing the rule on its own would have killed recording.

So we reversed the order of operations. ① First add an explicit allow rule for the recording path (server → camera 554), ② then correct the block rule. After applying, we separately verified that pings were blocked, that RTSP still connected, and that recording files were still being created. A block rule earns trust from a test that proves it blocks, never from the fact that it exists.

A couple of other things we handled: the RTSP password exists only in the environment file (0600) and is never left in plaintext in any document or repository. And the camera’s default admin password was changed, obviously.

Results and Checklist

The result is a CCTV recording system with six months of 2K retention, zero additional hardware spend and zero monthly subscription. Several weeks in, segments are recorded with no gaps, and automatic recovery after a server reboot has been confirmed in practice.

Checklist if you want to replicate this

  • • Confirm the camera supports RTSP and find its URL format (check the vendor manual)
  • • Pin the camera's IP with a DHCP reservation
  • • If possible, isolate it on an IoT-only VLAN and allow only recording server → camera 554/tcp
  • • Configure ffmpeg with -c copy + -rtsp_transport tcp + segment splitting
  • • Keep RTSP credentials in an EnvironmentFile (0600) and never in plaintext anywhere else
  • • Register it as a systemd service (Restart=always) and actually run a reboot test
  • • Use a retention script plus a systemd timer so cleanup happens before the disk fills
  • • Do not stop at writing block rules — verify they block, and that recording is still alive

For initial server setup, see our Linux server initial setup checklist, and for real-world office bandwidth measurements, see testing office network speed with iperf3.

T

Treeru

Sharing practical insights on web development, IT infrastructure, and AI solutions. Treeru — your partner in digital transformation.

Share

Related Posts

© 2026 TreeRU. All rights reserved.

All content is copyrighted by TreeRU. Unauthorized reproduction without attribution is prohibited.