Skip to content

Health

The uploader and cleanup services each serve a JSON health report and Prometheus metrics on a loopback port. Frigate has its own status interface; the clip worker has neither, because it has no state of its own beyond the database.

A fourth service, status, aggregates everything into one answer to is my CCTV working?.

Service Address Report
status http://<host>:8084/status The aggregate page: cameras, storage, backup, retention
status http://<host>:8084/health The collector's own report
status http://<host>:8084/metrics Prometheus text format
uploader http://127.0.0.1:8082/health Queue, B2 reachability, credential validity, backlog age
uploader http://127.0.0.1:8082/metrics Prometheus text format
cleanup http://127.0.0.1:8083/health Last retention run, held clips, disk free
cleanup http://127.0.0.1:8083/metrics Prometheus text format

8082 and 8083 are published on 127.0.0.1 only: use an SSH tunnel to reach them from another machine. 8084 is published to the LAN on purpose — it is the page you open from a phone to answer the question, it shows camera names and states, and it has no admin route and no credentials. Set STATUS_PORT to change the port, or remove the ports: entry from the status service to keep it loopback-only.

curl -s http://127.0.0.1:8082/health | jq .
curl -s http://127.0.0.1:8084/status  | jq '.overall, .answer'

The aggregate status page

Open http://<host>:8084/status in a browser. The first line is generated from state, not templated — "Recording is working. Cloud backup stalled 42 min ago." — and the details below it come from the same object the JSON is rendered from, so the page and the API cannot disagree.

It is read-only by design: no buttons, no forms, no JavaScript, so it keeps working when the rest of the stack is having a bad day. It reports what it could not observe instead of guessing: with Frigate down, the cameras read UNKNOWN and the headline says recording cannot be verified.

A camera that is reachable but has stopped producing clips is called out as QUIET rather than looking fine — that is the failure mode a simple camera-online check misses.

Dashboard and notifications

Gatus runs alongside the stack on http://<host>:3001 and owns two things this project deliberately does not build: history, and alert delivery. Its configuration is generated from cameras.yml, so a camera added to the config appears there without editing a second file.

Alerts go to ntfy on http://<host>:8085. Install the ntfy app, subscribe to the cctv topic, and that is the whole setup. Every alert has a matching recovery notification, and the generated policy fires only after three consecutive failures so a reconnect blip does not wake anyone.

What is watched, beyond the four services:

Monitor Kind Why
camera-<name> heartbeat One per camera, generated. A missing heartbeat alerts
disk heartbeat Free space and the projected days-to-full
backup-freshness heartbeat No successful upload for 30 minutes with a non-empty queue
retention heartbeat Cleanup has stopped completing passes
mosquitto TCP The broker is listening
frigate, the four services HTTP (?strict=1) A DEGRADED state alerts

If the status collector itself dies, its heartbeats stop and Gatus alerts on the silence — the tool does not depend on the thing it monitors to notice it is gone.

Reading a report

{
  "state": "DEGRADED",
  "summary": "1 check is degraded",
  "checks": [
    { "name": "state_db", "state": "HEALTHY", "detail": "schema 2" },
    { "name": "storage_reachable", "state": "HEALTHY" },
    { "name": "backlog_age", "state": "DEGRADED",
      "code": "UPLOAD_BACKLOG_AGE", "detail": "oldest pending clip is 52m old" }
  ]
}

The top-level state is the worst check. A service is never reported healthy because a single check passed.

State Meaning
HEALTHY Everything this service depends on is working
DEGRADED It is still doing its job, but something needs attention
UNHEALTHY It cannot do its job right now
UNKNOWN No recent observation, or the service has not completed a first pass
DISABLED Turned off by configuration, not broken

DISABLED is the expected state for cloud storage on a local-only install. If you see it and expected uploads, CCTV_STORAGE_BUCKET is empty.

Add ?strict=1 to get HTTP 503 when the state is DEGRADED or UNKNOWN, which is what a load balancer or uptime monitor wants.

What each check covers

Uploader:

Check Reports
state_db Database reachable, schema version readable
local_files_readable Clips that are due for upload can still be opened
storage_reachable B2 endpoint answers
storage_credentials B2 rejects or accepts the application key
failed_permanent Clips that exhausted their retries
upload_verification Whether uploads were verified by checksum, by size, or not at all

Only an upload with no provider confirmation at all (verified_by="none") is reported as DEGRADED. Above the multipart threshold the provider returns a composite digest, so the uploader compares size plus the local SHA-256 instead; that is the documented, expected result and it keeps the component healthy. | backlog_age | Age of the oldest pending clip | | upload_staleness | Time since the last successful upload |

Cleanup:

Check Reports
state_db Database reachable
clips_dir The clips directory exists and is readable
last_run Age of the last completed retention pass
clips_overdue_retention Clips held past their retention date because they are not uploaded
disk_free Free space, compared with the warn and critical thresholds

Cleanup also reports storage_reachable as DISABLED: cloud retention is not implemented yet, so it has nothing to check.

When something looks wrong

Start with the containers, then the services:

docker compose ps
docker compose logs --tail=100 frigate
docker compose logs --tail=100 clip-worker
curl -s http://127.0.0.1:8082/health | jq '.state, [.checks[] | select(.state != "HEALTHY")]'
curl -s http://127.0.0.1:8083/health | jq '.state, [.checks[] | select(.state != "HEALTHY")]'

Camera state lives in Frigate, not here. Frigate's image has no curl, but it does have python3:

docker compose exec -T frigate python3 -c "
import json, urllib.request
d = json.load(urllib.request.urlopen('http://127.0.0.1:5000/api/stats'))
print({name: cam['camera_fps'] for name, cam in d['cameras'].items()})
"

A camera with camera_fps of zero is not delivering frames, whatever the clip count says.

Admin endpoints

The uploader exposes two endpoints that are not mounted at all unless INTERNAL_API_TOKEN is set. They are for diagnosis, not automation.

TOKEN=$(grep '^INTERNAL_API_TOKEN=' .env | cut -d= -f2)

# Backlog summary
curl -s http://127.0.0.1:8082/admin/queue \
  -H "Authorization: Bearer $TOKEN" | jq .

# Requeue failed clips. A reason and a filter are both required.
curl -s -X POST http://127.0.0.1:8082/admin/retry \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"camera":"front-door","reason":"rotated the B2 key after 403 errors"}'

/admin/queue returns counts per upload state, oldest_pending_age_s, uploaded_24h, and the time of the last successful upload. Retrying without a reason, or without naming a clip, a camera, or a since timestamp, is refused with a 400.

See Recover failed uploads for what to do with the numbers.

Exit codes

Every service exits 78 when its configuration is invalid; Compose will show an exited container and the reason is in its log. The most common cause is a half-configured bucket: CCTV_STORAGE_BUCKET set, but CCTV_STORAGE_KEY_ID or CCTV_STORAGE_APPLICATION_KEY missing.

An uploader that cannot reach B2 does not exit. It starts, reports UNHEALTHY, and keeps the clips on disk.

What is not here yet

There is no aggregate status page, no alerting, and no notification integration. Each service reports on itself, and the operator checks those reports. If you run an uptime monitor, point it at /health?strict=1 on both ports.

Reconciliation findings

Once a day, cleanup compares the bucket listing against the database and writes what it finds to reconciliation_findings. It never fixes anything: every repair is a guess about which side is right, and deleting an "orphan" object can destroy the only surviving copy of a clip whose row was lost to a restore.

Finding Means Severity
dangling_row the row says an object exists; it does not critical
checksum_mismatch the stored object is not the bytes we recorded critical
key_mismatch cloud_key is not the key this version would build from the path critical
orphan_object an object no row references warning — it is billed
unexpected_local_file a file under clips/ with no row warning
missing_local_file a row whose file is gone warning

The reconciliation health check reports RECONCILE_FINDINGS when any critical finding is open, and RECONCILE_STALE when the daily pass has not completed for twice its interval. A finding is recorded once per subject while it stays unresolved, so a problem you have not fixed yet is one row, not one row per day.