Skip to main content

cctv-stack

PyPI Python versions CI

cctv-stack is a Python toolkit and container image for a self-hosted CCTV pipeline built on Frigate.

Frigate does camera ingest, detection, recording and review segmentation. cctv-stack adds the four pieces it leaves to the operator:

  1. Canonical clips — one deterministic file per review item.
  2. Durable backup — clips are uploaded to Backblaze B2 exactly once.
  3. Upload-aware retention — local cleanup never deletes a clip that is not safely uploaded.
  4. Health surfaces — the uploader and cleanup expose /health and /metrics.

It is not a replacement for Frigate. It is the missing layer around it.

Current release: 0.7.0. Clips, B2 upload, upload-aware local retention, the aggregate status page with Gatus/ntfy alerting, a guided one-command installer, ./scripts/doctor.sh diagnosis, encrypted backups, ./update.sh upgrades, two days of continuous footage kept locally, cloud retention that only deletes an object once the provider confirms it is gone, and a daily reconciliation that reports where the bucket and the database disagree.

Built since that tag, and in master today: housekeeping that bounds the database and tidies the clip tree, footage recovery from the bucket plus reconstruction of the database when there is no backup at all (./restore.sh clips|rebuild), a real test message to every alert channel during install, and a signed release bundle. force-delete and the single-instance lock are deferred deliberately — see CONTRIBUTING.md for the exact state and roadmap.


Install

On the machine that will do the recording, with Docker Engine 24+ and the Compose v2 plugin:

git clone https://github.com/aravindmyd/cctv-stack.git
cd cctv-stack
./install.sh

It asks four things, each with a default, so the common path is pressing Enter:

Question Example Notes
Location parents-home Lowercase; appears in every clip path and cloud object key
Timezone Asia/Kolkata Decides which date directory a clip is filed under
Cameras front-door + its RTSP URL Repeatable. The password is never echoed or printed back
Cloud backup B2 bucket + key, or skip Without it nothing leaves the machine, and nothing is ever deleted locally

Then it validates what it wrote through the same loader the services use, detects the host and sizes Frigate's shared memory, generates the two internal tokens, provisions the B2 bucket, starts the stack, waits for Frigate, and sends a real test message to every configured alert channel.

The URLs you need

Replace 192.168.1.10 with the machine's LAN address (hostname -I | awk '{print $1}').

What URL
Status page — is my CCTV working? http://192.168.1.10:8084/status
Gatus — dashboard and alert history http://192.168.1.10:3001
ntfy — subscribe a phone to topic cctv http://192.168.1.10:8085
Frigate UI — live views and recordings https://127.0.0.1:8971 on the machine itself, or ssh -N -L 8971:127.0.0.1:8971 user@host
Uploader / cleanup health http://127.0.0.1:8082/health, http://127.0.0.1:8083/health

The Frigate UI is HTTPS with a certificate Frigate generates itself, so the browser warns once. It binds to loopback on purpose: it is the one surface that can see every camera and change every setting.

Is it actually working?

Four checks, each answering a different question:

./scripts/doctor.sh                                  # 1. the machine's own verdict, read-only
curl -s http://192.168.1.10:8084/status | jq .cameras # 2. every camera delivering frames
ls -lt data/media/recordings | head                  # 3. footage landing on disk
find data/clips -name '*.mp4' -newermt '-10 minutes'  # 4. a clip exists — the entire point

docs/install.md is the full guide — prerequisites, every question the installer asks, why each URL is reachable from where it is, step-by-step verification, phone alerts, adding a camera later, upgrading, and a troubleshooting table. There is also a by-hand Compose path and a pip path for running the services against a Frigate you already have.

The Python package is usable on its own for the parts that read Frigate's API:

pip install cctv-stack

Requires Python 3.12+. The distribution is cctv-stack; the import package is cctv.

Optional extras:

Extra Adds Needed by
mqtt paho-mqtt cctv-clip-worker when subscribing to frigate/reviews
storage boto3 cctv-uploader for B2/S3
dev pytest, ruff Contributors
pip install "cctv-stack[mqtt,storage]"

Container image

docker pull ghcr.io/aravindmyd/cctv-stack:0.7.0

Tags published per release: :0.7.0, :0.7, and the commit tag :sha-<short>. A moving :edge tag follows master. If the registry package is private, run docker login ghcr.io first. The image includes ffmpeg/ffprobe.

Commands

Command What it does
cctv-config validate --cameras cameras.yml Validate the camera configuration. Exits 78 (EX_CONFIG) on an invalid config
cctv-config generate --cameras cameras.yml --out frigate.yml Generate Frigate's configuration from cameras.yml
cctv-clip-worker Turn completed Frigate reviews into canonical clips
cctv-uploader Upload clips to B2 through the persisted retry state machine
cctv-cleanup Enforce local retention after a successful upload
cctv-status Serve the aggregate status page (:8084/status), health and metrics
cctv-host Detect this machine's host profile and Frigate's shm_size
cctv-init Answer the install questions and write cameras.yml and .env
cctv-provision Create the bucket, set versioning, write the expiry rule, prove it
cctv-doctor Diagnose a whole installation, read-only (./scripts/doctor.sh)
cctv-backup Encrypted config bundle, online database backup, verification
cctv-recover Download every clip from B2 (clips); reconstruct the clips table (rebuild)
cctv-notify Send a real test message to every configured alert channel
cctv-release Assemble the signed release bundle (bundle, inspect, override)
cctv-migrate Apply pending migrations between stopping and starting services

The four services are long-running and configuration-driven; they take no command-line arguments. Run them under systemd, Docker, or Compose.

With Compose, the answer to is my CCTV working? is at http://<host>:8084/status; the dashboard and history are at http://<host>:3001 (Gatus), and alerts arrive through ntfy at http://<host>:8085.

Quick start

The only required input is a cameras.yml. This minimal file is valid; ${VAR} values are read from the environment and an unset variable is a hard error.

site:
  location: home
  timezone: UTC

cameras:
  front-door:
    display_name: Front Door
    rtsp:
      main: ${CAMERA_FRONT_DOOR_RTSP}
      sub: ${CAMERA_FRONT_DOOR_SUB}

Validate it and generate Frigate's config:

export CAMERA_FRONT_DOOR_RTSP='rtsp://user:pass@192.168.1.10:554/stream1'
export CAMERA_FRONT_DOOR_SUB='rtsp://user:pass@192.168.1.10:554/stream2'

cctv-config validate --cameras cameras.yml
cctv-config generate --cameras cameras.yml --out frigate.yml

validate checks the camera model and the environment substitution. generate writes a Frigate config with a GENERATED FILE — DO NOT EDIT header.

Running the services

export CCTV_CONFIG_DIR="$PWD"
export CCTV_DATA_DIR="$PWD/data"
export CCTV_FRIGATE_URL="http://127.0.0.1:5000"

cctv-clip-worker

Run each service in its own process. cctv-clip-worker works without MQTT (sweep-only); set CCTV_MQTT_HOST to receive reviews immediately. cctv-uploader reports DISABLED and uploads nothing when CCTV_STORAGE_BUCKET is empty; a bucket with no key exits 78.

ffprobe must be on PATH for the clip worker. The container image includes it; a pip install does not install ffmpeg.

Configuration

There are two kinds of configuration, and they must not be mixed:

File Holds Secret?
cameras.yml Site name, timezone, cameras, clip policy, retention No — but ${VAR} values come from the environment
environment / .env RTSP URLs, B2 keys, paths, ports, intervals Yes

The full template is .env.example. The settings most consumers need:

Variable Default Meaning
CCTV_CONFIG_DIR /config Directory containing cameras.yml
CCTV_DATA_DIR /data Root for clips/ and the state database
CCTV_DB $CCTV_DATA_DIR/cctv.db SQLite state store
CCTV_FRIGATE_URL http://frigate:5000 Frigate API base URL. Port 5000 must stay on a private network
CCTV_MQTT_HOST unset Unset means sweep-only; no broker required
CCTV_SWEEP_INTERVAL_S 60 Worst-case delay before a missed review is noticed
CCTV_STORAGE_BUCKET empty Empty means cloud backup DISABLED
CCTV_STORAGE_KEY_ID empty B2 application key ID
CCTV_STORAGE_APPLICATION_KEY empty B2 application key
CCTV_STORAGE_REGION us-west-004 B2 region
CCTV_UPLOADER_HEALTH_PORT 8082 Uploader /health and /metrics
CCTV_CLEANUP_HEALTH_PORT 8083 Cleanup /health and /metrics
INTERNAL_API_TOKEN empty Enables the uploader admin endpoints when set

Keep .env out of version control. RTSP URLs and B2 keys are credentials.

Camera names must be lowercase kebab-case (front-door): they become directory names and cloud object keys.

What it produces

$CCTV_DATA_DIR/
├── clips/
│   └── <camera>/YYYY/MM/DD/HH-MM-SS.mp4
│       <camera>/YYYY/MM/DD/HH-MM-SS.mp4.json
└── cctv.db

The clips tree is rendered in the site timezone. The sidecar records the review id, event ids, requested boundaries, measured coverage, codecs, size and SHA-256. The cloud object key is <location>/clips/<path-inside-clips>.

A clip appears after the visit ends: Frigate keeps the review open until its alert cutoff passes. There is no partial clip by design.

Health endpoints

Service Endpoint Notes
cctv-uploader http://127.0.0.1:8082/health, /metrics DISABLED without a bucket
cctv-cleanup http://127.0.0.1:8083/health, /metrics Local retention state
Uploader admin /admin/queue, /admin/retry Mounted only when INTERNAL_API_TOKEN is set

Bind these to loopback. Nothing in the package needs an inbound port from the LAN.

Python API

The supported contract is the CLI, the environment variables, and the HTTP health endpoints. The importable modules are tested, but pre-1.0 names may change between minor versions.

from cctv.config import ConfigError, load_cameras_config

try:
    config = load_cameras_config("cameras.yml")
except ConfigError as exc:
    raise SystemExit(f"invalid camera config: {exc}")

print(config.site.location)
for name, camera in config.enabled_cameras().items():
    print(name, camera.display_name, camera.record.mode)

Storage is available behind the storage extra:

import os
from cctv.storage import S3StorageAdapter

adapter = S3StorageAdapter(
    bucket=os.environ["CCTV_STORAGE_BUCKET"],
    access_key_id=os.environ["CCTV_STORAGE_KEY_ID"],
    secret_access_key=os.environ["CCTV_STORAGE_APPLICATION_KEY"],
    region=os.environ.get("CCTV_STORAGE_REGION", "us-west-004"),
)

cctv.__version__ reports the installed package version.

Complete installation

For a full CCTV site — Frigate, MQTT, the worker, uploader and cleanup — use the Compose deployment in this repository:

git clone https://github.com/aravindmyd/cctv-stack.git
cd cctv-stack
./install.sh

The first run creates .env and config/cameras.yml; edit both and run it again. See docs/install.md for the operator procedure and CONTRIBUTING.md for how releases are built and delivered.

Compatibility

Python 3.12 or newer
Frigate 0.18 API and MQTT contract (verified)
Platforms Linux x86_64/arm64; macOS for development
Cloud backup Backblaze B2 through its S3-compatible endpoint; optional
Inbound network None required

Documentation

Full documentation: https://aravindmyd.github.io/cctv-stack/

License

Apache-2.0 — see LICENSE.

Metadata

Release files for cctv-stack 0.9.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cctv-stack 0.9.1
File Size Uploaded Provenance
cctv_stack-0.9.1.tar.gz 227.0 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for cctv-stack 0.9.1
File Interpreter ABI Platform
cctv_stack-0.9.1-py3-none-any.whl Python 3 none any Details

Total release size: 477.7 kB

Release files / cctv_stack-0.9.1.tar.gz

Download URL cctv_stack-0.9.1.tar.gz
Size 227.0 kB
Tags Source
SHA-256 checksum
How to use checksums
6dc6f926d590d830e6efc221e5b2b9ecf5f431cd28769235bc092b77efdfc407
BLAKE2b-256 checksum
How to use checksums
c091bcca93b1e7a0e25267c5989799e5aa723f0c17ecf489a433692538ab0cde
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

Release files / cctv_stack-0.9.1-py3-none-any.whl

Download URL cctv_stack-0.9.1-py3-none-any.whl
Size 250.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
549bb762321c5c6f9afa1c185dd0e246cf08c83b9ba77f4259628c692ea0945f
BLAKE2b-256 checksum
How to use checksums
22d9784630829ee18be5637210ebf997e70495b874bd0e8a747c1102defc5ad8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

Security for cctv-stack 0.9.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for cctv_stack-0.9.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page