A tiny, self-hosted dashboard for watching many local IP cameras at once. Designed to run on a Raspberry Pi, a Mac, or any small Linux box on your LAN.
- Auto-discovers ONVIF cameras on the local network (WS-Discovery)
- TCP/ARP/OUI scan + active HTTP fingerprinting catches cameras that don't speak WS-Discovery (Reolink, including doorbells)
- Pulls RTSP and snapshot URLs automatically once you pick a vendor
- Multi-select "Add all" with one shared credential entry
- Renders 10, 15, 20+ cameras in a browser grid
- Custom layouts: equal grids, "1 large + grid", "Cinema", …
- Inline rename, drag-and-drop reorder, per-camera poll rate
- Double-click any tile for a fullscreen zoom view
- Reolink token-auth + RTSP frame-grab fallback (works even when the HTTP API session pool is saturated by other clients)
- Remembers layout, names, sort order — persisted in SQLite
- No cloud, no account, no telemetry. MIT licensed.
- Install on macOS (auto-start)
- Install on Raspberry Pi / Linux
- Install with Docker
- First run: adding cameras
- Configuration
- Security & credentials
- Updating
- Troubleshooting
- API reference
- How snapshots work
# Prerequisites
brew install node ffmpeg
# Clone + install
git clone https://github.com/dsmagghe/freelocalcamviewer.git
cd freelocalcamviewer
npm install
# Register as a LaunchAgent — boots with the user session,
# restarts on crash. Port argument is optional (defaults to 8088).
scripts/install-mac.sh 8088Open http://localhost:8088.
What this does:
- Writes
~/Library/LaunchAgents/be.openview.freelocalcamviewer.plist - Loads it with
launchctlso the app starts on every login - Logs to
data/launchd.out.loganddata/launchd.err.log
Change the port: re-run the installer with a new port argument.
scripts/install-mac.sh 9099Uninstall:
scripts/uninstall-mac.shffmpeg is technically optional but strongly recommended — it enables
the RTSP-grab fallback that keeps snapshots working when a camera's
HTTP API sessions are all taken by other clients (the Reolink app,
an NVR, …).
sudo apt update
sudo apt install -y nodejs npm build-essential ffmpeg
sudo mkdir -p /opt/freelocalcamviewer
sudo chown -R "$USER" /opt/freelocalcamviewer
git clone https://github.com/dsmagghe/freelocalcamviewer.git /opt/freelocalcamviewer
cd /opt/freelocalcamviewer
npm install
sudo cp scripts/freelocalcamviewer.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now freelocalcamviewerOpen http://<pi-ip>:8088.
Tail logs: sudo journalctl -u freelocalcamviewer -f
Restart: sudo systemctl restart freelocalcamviewer
Change port: edit Environment=PORT=... in the unit file and sudo systemctl daemon-reload && sudo systemctl restart freelocalcamviewer
On a Pi 4 the grid handles ~15–20 cameras at the default 400 ms poll
without trouble. If you push past that, raise poll_ms per camera
(2000 ms is a comfortable monitoring rate).
git clone https://github.com/dsmagghe/freelocalcamviewer.git
cd freelocalcamviewer
docker compose up -dThe compose file uses network_mode: host so the container can send
the ONVIF WS-Discovery multicast probe and reach LAN cameras directly.
On Linux this works out of the box; on Docker Desktop (Mac/Windows)
host networking is limited — auto-discovery may not return results,
but you can still add cameras manually with their IP.
The DB is mounted from ./data/ so your camera list survives container
rebuilds.
- Open the app, click Scan LAN.
- The scan combines three methods:
- ONVIF WS-Discovery (multicast, ~5 s)
- TCP probe of every IP in your
/24on common camera ports - ARP + OUI lookup, plus an active HTTP probe of unknown-vendor hosts
- Click Select all in the scan modal, then Add selected.
- Enter your camera username and password — once. They are reused for every selected camera.
- The vendor template fills in RTSP + snapshot URLs automatically; tiles light up within a few seconds.
For cameras already in the list that you want to rename: click the name in the tile, type a new name, press Enter. Done.
For grid layout: pick from the Layout dropdown (equal grids 1–6 columns, or featured layouts where the first N tiles are bigger). Reorder which camera gets the big slot with Edit layout + drag.
All settings are environment variables:
| Variable | Default | Purpose |
|---|---|---|
PORT |
8088 |
HTTP port |
HOST |
0.0.0.0 |
Bind address (127.0.0.1 for local-only) |
DB_PATH |
./data/cameras.db |
SQLite database file |
The LaunchAgent and systemd unit both set these — edit the unit file or re-run the installer to change them.
Per-camera settings live in the DB:
name,host,port,username,passwordrtsp_url,snapshot_urlvendor(drives the auth strategy)enabled(Pause/Play button on each tile)sort_order(drag-and-drop)poll_ms(per-camera refresh rate, default 400 ms)
This app is intended for trusted LAN use only. There is no built-in authentication. Anyone on your LAN who can reach the port can see every camera.
What is stored where:
- Camera credentials are kept in plain text in
data/cameras.db. - The
data/directory is in.gitignoreso accidentalgit add -Adoes not push your password to a public repo. - Logs (
*.log,launchd.*.log) are also gitignored, because Node occasionally prints an URL with credentials on error.
To expose it beyond the LAN, do not just port-forward. Use one of:
- Tailscale or another mesh VPN
- A reverse proxy with HTTP Basic Auth in front (Caddy, nginx)
- An SSH tunnel from the client device
Treat data/cameras.db as a secret file. Back it up encrypted,
chmod it 600, don't share it.
cd /opt/freelocalcamviewer # or wherever you cloned it
git pull
npm install # in case dependencies changed
# macOS LaunchAgent:
launchctl unload ~/Library/LaunchAgents/be.openview.freelocalcamviewer.plist
launchctl load ~/Library/LaunchAgents/be.openview.freelocalcamviewer.plist
# systemd:
sudo systemctl restart freelocalcamviewer
# Docker:
docker compose up -d --buildSchema migrations are additive and run automatically on boot.
- If you're connected to a VPN, the multicast probe is likely going out the tunnel. Disconnect the VPN, scan again.
- The TCP-probe step works regardless of VPN, so you should still see raw hosts. If even that finds nothing, check that you're on the same Wi-Fi as your cameras.
The stamp text shows the actual upstream error. Common ones:
| Error | Meaning |
|---|---|
please login first / rspCode -6 |
Token-auth failed or expired. Backs off, retries. |
max session / rspCode -5 |
The camera's HTTP API session pool is full of other clients. RTSP-grab via ffmpeg kicks in as fallback. |
auth_warning_info / remain_time |
Account is temporarily locked after wrong passwords. Wait or power-cycle the camera. |
Connection refused |
Camera is offline or in defensive lockout. Power-cycle. |
password wrong |
The credentials on this camera really are different. Click Edit to fix. |
That's the RTSP-grab fallback. Raise the camera's poll_ms to
something more relaxed (e.g. 2000 ms) — or fix whichever client is
holding all the HTTP sessions and the faster reolink-token path will
take over again.
Open Edit on the camera, set Vendor to reolink / hikvision /
dahua / etc. — the server fills in RTSP + snapshot URL from the
template automatically when vendor changes.
| Method | Path | Notes |
|---|---|---|
GET |
/api/cameras |
List |
POST |
/api/cameras |
Create one |
PATCH |
/api/cameras/:id |
Partial update; changing vendor re-fills URLs |
DELETE |
/api/cameras/:id |
Remove |
POST |
/api/cameras/batch |
Multi-create with one shared credential set |
POST |
/api/cameras/reorder |
{order: [id, id, …]} |
POST |
/api/scan |
Combined ONVIF + TCP + ARP/OUI + HTTP probe |
POST |
/api/discover |
ONVIF WS-Discovery only (legacy) |
POST |
/api/probe |
ONVIF probe a known IP |
POST |
/api/vendor-template |
Return suggested URLs for a vendor + host |
GET |
/api/cameras/:id/snapshot |
Proxied JPEG (or 502 with JSON attempt log) |
GET |
/api/settings/default-credentials |
Returns the most-recent username/password |
Each tile polls /api/cameras/:id/snapshot. Under the hood the proxy
walks a method ladder until one succeeds:
reolink-token ─┐
url-creds ─┴── http-api auth class (uses camera's HTTP session pool)
rtsp-grab ──── rtsp auth class (uses ffmpeg, separate pool)
The winning method is cached per camera so subsequent polls go straight to it. Failures are tracked per auth-class — so when the HTTP API is rate-limited, RTSP-grab still runs.
┌────────────────────┐ ONVIF / api.cgi / RTSP ┌──────────────┐
│ FreeLocalCamViewer├────────────────────────────▶│ IP camera │
│ Node.js + Express │◀───── JPEG / H264 ─────────│ │
└────────┬───────────┘ └──────────────┘
│ HTTP poll (no transcoding)
▼
┌──────────┐
│ Browser │
└──────────┘
MIT — see LICENSE.