nightlight

nightlight

Docker app from sauso's Repository

Overview

Nightlight is a self-hosted baby monitor with low-latency multi-camera streaming from RTSP cameras, installable as a web app (PWA) on your phone. No cloud, no subscription - runs entirely on your own network. One container includes the app, MediaMTX (RTSP to WebRTC/HLS bridge), and FFmpeg (audio transcoding) together. Nightlight needs a routable LAN address so WebRTC (the low-latency viewing mode) works correctly. Host networking (the default below) is the simplest way to get one and needs no extra setup; a dedicated ipvlan/macvlan address on a custom Docker network is also supported if you'd rather avoid host-mode port conflicts - see the Networking modes section of the README (github.com/sauso/nightlight) for both options. Set the data path below to somewhere under your appdata share before starting. After starting, open the WebUI and you'll be prompted to create the first admin account.

Nightlight — self-hosted baby monitor

A mobile-friendly web app for watching multiple RTSP cameras, grouped by child, over your home network. Live video uses WebRTC (via MediaMTX) for sub-second latency — much lower than a typical HLS-based viewer. Use it in any browser, install it to your home screen as a PWA, or run the companion native apps for Android and iOS (nightlight-mobile) for reliable background audio and picture-in-picture (see Mobile apps below). No cloud, no subscription, no account anywhere but your own network.

⚠️ Not a safety device. Nightlight is a convenience tool, not a safety device. It is not a medical device, is not certified for safety monitoring of any kind, and must never be used as a substitute for adult supervision. Streams can drop, apps can be killed by the operating system, networks fail — never rely on this software to alert you to a child in distress.

Screenshots

See the visual walkthrough for a tour of the main screens (sign-in, the nursery dashboard, adding a camera, and settings). Those images are generated automatically by the end-to-end test suite, so they stay in sync with the actual UI.

How it works

  • FFmpeg pulls each camera's RTSP stream, copies the video through untouched, and transcodes just the audio to AAC (many IP cameras send audio as G711, a codec HLS can't carry at all — WebRTC can, which is why this only matters for Compatibility/HLS mode). The result is published into MediaMTX.
  • MediaMTX re-publishes that as WebRTC (WHEP) and HLS, which browsers can play natively — RTSP itself cannot be played in a browser, so this bridge is required either way.
  • Backend (Node/Express + SQLite) stores children, cameras, and caregiver accounts, and manages both MediaMTX and one FFmpeg process per camera as child processes — starting them, restarting on crash, and stopping them when a camera's removed.
  • Frontend (React) is a mobile-first, installable app: a live dashboard grouped by child, plus screens to manage children, cameras, and caregiver accounts. Cameras can be added by IP over ONVIF (auto-filling the stream details), and PTZ (pan/tilt) cameras get on-screen controls — see Managing cameras.

Everything above runs in a single Docker container, which needs its own routable IP on your LAN so WebRTC connects without NAT/ICE headaches (MediaMTX advertises that IP to browsers). The simple default is host networking — the container shares the host's IP — used by the quick start and the Unraid template. If host mode doesn't suit you (e.g. a port clash with another service), you can instead give the container its own dedicated LAN IP; see Networking modes below.

This is designed for local network use by default — see "Remote / internet access" below if you want it reachable from outside your home too.

Quick start

Pull and run directly from Docker Hub:

docker run -d \
  --name nightlight \
  --network host \
  --restart unless-stopped \
  --log-opt max-size=10m \
  --log-opt max-file=3 \
  --stop-timeout 30 \
  -e PUID=99 \
  -e PGID=100 \
  -e TZ=UTC \
  -v /path/to/your/data:/app/data \
  sauso/nightlight:latest

Nightlight records video three ways — automatic clips attached to motion/sound alerts (opt-in per camera), wake clips saved silently when your child wakes up, and on-demand recordings you capture with the Record button. They have different retention rules, so see docs/recording.md for all of it and for every setting under Settings → Recording. Video defaults to <data dir>/clips; you can point it at your array instead with a /recordings mount + CLIPS_DIR.

PUID/PGID control which user/group owns files this container creates in your data directory - the defaults above (99/100) match Unraid's own "nobody"/"users" convention, so they're usually already correct there. On another system, find your own with id your_username. TZ (e.g. Australia/Melbourne) affects log timestamps only - full list of values.

The --log-opt flags cap Docker's own log storage at 10MB × 3 files - without them, logs default to growing unbounded, which can be a real problem on Unraid specifically since Docker's storage there is a fixed-size image that can break the whole Docker service if it fills up.

--stop-timeout 30 gives Nightlight enough time to stop cleanly. It needs a few seconds on the way down — an on-demand recording is assembled from the buffer at that point, and being killed part-way through loses it. Stopping normally takes 1-5 seconds, so 30 is deliberately generous — and costs nothing, because the container exits as soon as it's finished rather than waiting out the timeout. Don't rely on Docker's own default here: recent versions don't document one, and it has been measured killing the container after about 4 seconds, which was enough to lose the recording. If you deployed before this was added, add --stop-timeout 30 yourself (or stop_grace_period: 30s under the service in Compose, or the Unraid template's "Extra Parameters" field). Nothing else breaks without it — a recording lost this way is marked failed rather than disappearing silently.

Or with Docker Compose:

cp .env.example .env
# optionally edit .env - see comments in the file; the defaults are fine to start
docker compose up -d

Then, from any phone/laptop on the same network, visit http://<server-ip>:4000. The first time you visit, you'll be asked to create the admin account — do this first, from a trusted device. Then add your children (Children tab), add your cameras (Cameras tab — by IP via ONVIF, or by RTSP details; see Managing cameras), and assign cameras to children.

Requirements

  • Any always-on Linux box on the same network as your cameras (a Raspberry Pi, an Unraid server, anything running Docker). Both amd64 and arm64 are supported.
  • Cameras that expose an RTSP stream (almost all "dumb" IP cameras and most smart cameras with a local RTSP option do — check the camera's manual for the RTSP path, usually something like /stream1).
  • ONVIF is optional but handy: if a camera supports it, Nightlight can fetch the stream details from just its IP, and enable pan/tilt controls on cameras that report PTZ.

Running on Unraid

An Unraid Community Applications template is included (unraid-template.xml). Until this is submitted to the official CA feed (at which point it'll be searchable directly from the Apps tab), install it locally by placing the file where Unraid looks for user templates:

  1. Open the Unraid Terminal (or SSH in), then run:
    mkdir -p /boot/config/plugins/dockerMan/templates-user
    wget -O /boot/config/plugins/dockerMan/templates-user/my-nightlight.xml \
      https://raw.githubusercontent.com/sauso/nightlight/main/unraid-template.xml
    
  2. Docker tab → Add ContainerTemplate dropdown → select nightlight. Every field (network mode, data path, optional variables) is pre-filled from the template — double check the Data Directory path if you want something other than the default (/mnt/user/appdata/nightlight), then Apply.

This is a single container — no extra plugins needed, Unraid's normal Docker UI handles it directly.

Already installed from an older copy of the template? Check that Extra Parameters (Advanced view, on the container's edit page) contains --stop-timeout 30, and add it if not. Unraid builds the container from the saved template, so a template downloaded before this was added keeps the old value until you edit it — and without it a recording that was in progress when the container restarts can be cut short. See the note under Quick start for what it does.

Networking modes

Nightlight needs its own routable IP on your LAN so WebRTC has no NAT/ICE trouble — MediaMTX advertises that IP to browsers. There are two supported ways to provide one:

  • Host networking (default, simplest). The container shares the host's IP (--network host, or <Network>host</Network> in the Unraid template). Nothing to configure — it's what the quick start and the template use. The trade-off is that it binds its ports (4000, plus MediaMTX's) directly on the host, which clashes if something else there already uses them.
  • A dedicated LAN IP (ipvlan / macvlan). Put the container on a Docker ipvlan/macvlan network so it gets its own address on your LAN, separate from the host. Nightlight auto-detects the container's IP and has MediaMTX advertise it, so WebRTC works on the LAN with no extra configuration (no PUBLIC_HOST needed for local viewing). Use this to avoid host-mode port conflicts, or just to keep the app on a tidy fixed IP of its own.
    • On Unraid: create a custom Docker network on your NIC (e.g. br0, or a VLAN like br0.10), then in the container template set the Network Type to that network and give it a fixed IP.
    • macvlan caveat: with plain macvlan, the host itself usually can't reach the container (a Linux kernel limitation) — which matters if your reverse proxy (e.g. SWAG) runs on that same host. Use ipvlan (or add a macvlan shim interface) so the host can reach it.
Host networking ipvlan macvlan
Setup None — the default Custom Docker network + fixed IP Custom Docker network + fixed IP
Port collisions with other containers Possible — shares the host's ports None — has its own IP None — has its own IP
Host itself can reach the container Yes (it is the host) Yes No (Linux kernel limitation) — use ipvlan instead if a reverse proxy runs on the host
Multiple instances on one host Awkward — only one can hold port 4000 Easy — each gets its own IP Easy — each gets its own IP

Either way, the app is still served on port 4000 (change it with the PORT env var if you need to). For remote / internet access, see "Remote / internet access" below — that part is the same in both modes.

Running more than one instance (e.g. staging + production)

Running two copies on one host is easiest with the dedicated LAN IP mode above: give each its own IP and they can keep the same internal ports without clashing (host networking can't — two host-mode containers would both try to grab port 4000). Set a different PORT on an instance if you'd rather change its HTTP port instead.

Watching both remotely in Low latency (WebRTC) takes one extra step, because each instance's WebRTC media needs its own UDP port forwarded 1:1 — the port a browser must reach has to match the port MediaMTX advertises, so a router that remaps the port breaks WebRTC:

  1. Give each instance a distinct WebRTC UDP port: leave one at the default 8189 and set the other with the MTX_WEBRTCLOCALUDPADDRESS env var, e.g. MTX_WEBRTCLOCALUDPADDRESS=:8190.
  2. Forward each 1:1 on your router — external UDP 8189 → instance A's 8189, external UDP 8190 → instance B's 8190 (same number in and out).
  3. Set PUBLIC_HOST (your public IP or DDNS) on both.
  4. Put each behind its own subdomain in SWAG (e.g. nursery.example.com, nursery-dev.example.com); the HTTP and the WebRTC signalling both ride 443, so the only per-instance forwards are those two UDP media ports.

Compatibility (HLS) needs none of this — it's all HTTP over 443, so both instances work remotely with no extra port forwarding.

Managing cameras

Cameras are added and edited from the Cameras tab (admin only). A camera's address is entered as separate fields — IP address, RTSP port (usually 554), stream path, and the camera's username / password. Nightlight assembles the rtsp:// URL from those itself, so the password never appears in a URL on screen.

Supported cameras — Nightlight works with ONVIF / RTSP cameras in general: video, audio, and — on ONVIF cameras — pan/tilt and two-way audio. The fully tested and recommended option is open-firmware Thingino cameras (inexpensive, ONVIF, pan/tilt, two-way audio); on those, set the camera's audio codec to G711 (a-law) for reliable sound (some builds default to AAC, which not all of them stream well — see KNOWN-ISSUES.md). Most other ONVIF cameras work too, and Hikvision two-way audio (ISAPI) is supported. If a camera won't connect, the Add camera screen can generate a redacted camera report to help add support for it. The report is a JSON file that stays on your device — it holds the camera's address, username, ONVIF result and stream codecs, with any password replaced by ***. It is meant to be attached to a GitHub issue, so it is worth opening and reading before you post it: the redaction covers passwords, not everything a particular camera's firmware might put in an error message. If you find something sensitive that shouldn't be there, see SECURITY.md for how to report it privately instead.

Adding a camera

  • Via ONVIF (easiest): type the camera's IP and press Fetch port & path from ONVIF. Nightlight queries the camera and fills in the port and stream path for you, and detects whether it supports pan/tilt and two-way audio. Most cameras answer this without a login; if yours needs one, fill in the username/password first. Then make sure the login is entered and Save.
  • Manually: type the IP, port, path, and login yourself (see the camera's manual for its RTSP path, e.g. /stream1).
  • On Save, Nightlight briefly tests the stream first and won't store a camera it can't reach (wrong login, path, or IP) — it tells you why. If the camera just happens to be offline right then, you can choose Save anyway.

Capability badges — cameras added via ONVIF show badges in the list: PTZ and Two-way Audio, green when supported, red when not. (Manually-added cameras show neither, since their capabilities aren't probed.)

Pan/tilt (PTZ) — on cameras that report PTZ, a move button appears on the camera tile (next to the stream-quality gear). Tap it for an on-screen D-pad: each press nudges the camera a fixed amount, and holding an arrow keeps it moving; it stops on release and can't run past the limit.

Two-way audio (talk-back) — on cameras that support it, a talk button appears on the tile: hold it to speak through the camera's speaker. Works with Hikvision (over ISAPI — enter the camera's web login when adding) and with any ONVIF camera that has an audio backchannel (e.g. Thingino/Sonoff), which is set up automatically from the ONVIF probe using the camera's own stream login.

Editing — Edit shows the same fields. Leave the password blank to keep the existing one — it's never sent back to your browser. Changing the address re-tests the stream, the same as adding.

Removing — Remove asks for confirmation, then stops the stream and deletes the camera. You can always add it again.

Assigning to a child — use the "Assigned to" dropdown on each camera. (This is just for grouping the dashboard; any signed-in user can change it.)

Sleep tracking

Nightlight can estimate each child's overnight sleep from what their cameras already see and hear — no wearables, no extra hardware. It's a sleep-pattern guide, not a medical measurement, and (like everything here) never a safety device — see the warning at the top.

  • Turn it on per child. Each child has a Track sleep toggle in their settings, with their own bedtime and wake time. Turning tracking off stops it entirely. A child can have more than one camera — their movement and sound are combined.
  • Bedtime is a guide, not a boundary. Real bedtimes move night to night, and you shouldn't have to edit the setting each evening. The bedtime that gets reported is the one the camera actually saw — the put-down, and the child settling after it — whether that happens before the window opens or an hour into it. If your child was already asleep before the window, that sleep is counted and the night's timeline starts at the real bedtime rather than at the setting. Either way it only happens when the camera saw the child actually put into bed — a quiet room on its own is never read as a sleeping child, and neither is a put-down on its own: the bed also has to go on showing signs of being occupied afterwards. A sleeping child is never perfectly still for hours — they move a little, repeatedly — so a bed that stirs once and then nothing is an empty one, and the bedtime it seemed to start is discarded. The same applies at the other end: a morning wake is still found if it comes after the configured wake time.
  • One camera does the measuring. If a child has several cameras, sleep is worked out from their main camera — the first one in the order you've arranged them, skipping any that are turned off. The others carry on streaming, alerting and recording as normal; they just don't affect the numbers. This is deliberate: if every camera were combined, the noisiest one would decide the night — a camera facing the doorway would push bedtime later and add wake-ups that never happened, with nothing saying which camera was responsible. The night's detail view names the camera it measured from. Room temperature and humidity are the exception and still come from every sensor in the room, because those are averaged rather than combined.
  • How it estimates. Across the night it builds a per-minute movement + sound timeline from the child's main camera: falling still for a sustained stretch reads as falling asleep, sustained movement or noise reads as an awakening (brief stirs don't count). On a completed night, a short recorded exit-and-return can also count as an awakening even when it's too brief to show up as sustained movement on its own — as long as the bed is confirmed quiet in between and later bed movement supports the return. This only applies to trips of one to twenty minutes, with at least a minute of quiet bed time in between; it updates wake-ups, awake time, and the longest sleep stretch, but live figures and wake-clip recording still use the plain movement rule above. If you've painted a bed zone on the camera — the same area that scopes motion alerts — it also tracks movement outside the bed and lists it separately, which catches a morning wake where the child has already left the bed. Draw the zone so it comfortably contains the child including where their head ends up — a zone that cuts through a sleeping child makes their own rolling over register as movement outside the bed, and can stop a real climb-out being recognised. Only clearly-outside-the-bed movement is listed; faint changes (a shadow, the camera's night-vision adjusting) are ignored rather than reported as someone in the room.
  • Getting up for the day. The morning wake is the point the bed empties and stays empty, rather than the last movement seen in it — otherwise a parent stripping the bed an hour later would be reported as the child waking. A single stray minute of movement in an otherwise still bed (an adult reaching in for a toy or a blanket) does not restart that count; several minutes together do, because that is a person at the bed rather than a passing arm. The wake is only accepted where a recorded got out of bed backs it up, so a quiet spell alone can never end the night early.
  • A child who gets out and climbs straight back in has not got up. If a got into bed follows a got out of bed shortly after, the night carries on rather than ending there — and so does a second got out of bed logged within a minute of that return, because climbing back into a bed and leaving it again inside a minute is one movement being read twice, not two trips. This matters most for a very still sleeper: once they settle, their bed can look identical to an empty one for hours, so a false exit shortly after bedtime would otherwise be reported as the end of the night. A qualifying short trip can still be counted as a wake-up within that same continuing night — see "How it estimates" above. It can miss a trip that's interrupted by a data gap or a stray reading, and it cannot tell an adult's visit to the bed from the child's own trip out of it. Known limit: a genuine departure less than a minute after getting into bed is read the same way, because the two are not distinguishable from what the cameras record. Where that is the only got out of bed of the night it is still used, so the night is never left with no wake time at all; where a later one exists, the later one is reported instead.
  • Tell it when it got a night wrong. The morning after, the child's page offers Was last night right? — confirm the times or correct them, and mark any recorded got into / out of bed event as right, wrong, or "can't tell" against the still frame it was decided from. It appears once per night and goes for good once answered or dismissed; nothing is asked about nights with no times to confirm. Either an admin or a caregiver can answer, deliberately — the person who was in the room at 5am is the one who knows. Type times on your own clock; they are recorded against the timezone in Settings, the same one the sleep card displays.
    • Confirming that a night was right is worth as much as correcting one — it is what makes a future change to sleep detection provable rather than arguable. Confirming and correcting are separate buttons on purpose: the times we guessed are never one stray tap from being recorded as fact.
    • Point at the picture instead of typing. Each recorded event has Put down here / Up for the day here — tap the frame that shows the real moment and the time is taken from it, exact to the second rather than rounded from memory. Typing a time by hand instead clears the picked frame, so only one of them is ever the answer.
      • This is not the same as marking an event correct. An exit can be perfectly real and still not be the end of the night — a child who gets out at 5:45, goes back, and gets up again at 6:00 had two genuine exits and only one of them ended the night.
    • Your times become the ones shown. Once you correct a night, the child's card, the history list and the sleep detail page all show your times, marked You corrected this, with the total sleep recalculated to match. This is different from Recompute this night, which re-runs the detector: correcting records what you know, recomputing re-asks the app.
    • The detector's own answer is kept underneath, not overwritten. That is deliberate — it is what a future improvement gets scored against. Nothing you enter here changes how sleep is detected.
    • The card confirms it. After you answer, the prompt becomes a short receipt showing what you recorded, and tapping it lets you change your mind.
    • Any night can be reviewed, not just last night. The sleep detail page has Was this night right? for whichever night you are looking at — that is how you correct a night you already answered, since the card only ever offers nights you haven't. Saving from there keeps you on that night and shows the receipt in place, so working back through a run of nights doesn't send you to re-pick the date each time.
    • Reviews are kept forever — unlike the sleep minute-data behind them (30 days) or the recorded events (45 days). They are a few hundred bytes a night, and their whole value is being comparable years later. A recorded event you have judged is also kept past the usual 45 days, along with its frame, so a labelled picture is never deleted on a timer.
  • Noise on its own doesn't delay bedtime. A bedroom microphone hears the whole house, and bedtime is usually its loudest hour — a sibling being settled, a TV, adults talking. So when working out when your child fell asleep, a noisy minute counts as awake only if that room also moved at around the same time. The same holds for the first half hour after they fall asleep, so the tail of the household's evening isn't reported as their first wake-up. After that the rule stops: mid-night the house is quiet, so a cry with no movement counts as a wake-up as you'd expect.
  • Re-working out a night (admins). A night's summary is worked out once, the morning after, and then kept as it is — so if sleep detection is improved later, an already-recorded night keeps showing the old answer while the detail view, which works the night out fresh each time you open it, shows the new one. Recompute this night on the sleep detail page reconciles them. It compares what is saved — the summary on the child's page — against what the recorded movement now says, and shows you exactly what would change (bedtime, wake time, how long they slept, how many wake-ups) before anything is saved. You can cancel. Admins only. It can never make a night worse: the minute-by-minute data behind a night is only kept for 30 days — the same span the date picker offers — so the oldest night you can browse sits right on that edge. If its data has aged out, the recompute is refused and the saved summary is left alone, rather than being replaced with "no data".
  • At a glance, and live. Each child's page summarises last night — total sleep, wake-ups, longest stretch — and while a night is in progress it updates as "Tonight · so far", so an early-morning wake appears within a minute or two rather than only after the window closes. Treat those live figures as provisional: a night in progress is judged only on what has happened so far, so a bedtime can be revised later in the evening and the morning wake isn't looked for at all until the night is complete. The settled numbers are the ones on the card the following day.
  • The detail view. Tap the sleep summary for the full night timeline: a to-scale bar of asleep / stirring / awake stretches on a real time axis, every wake-up listed with its time and length, a movement outside the bed list, and a date picker to step back through roughly the last month of nights. Two moment markers sit on the bar — got into bed and got out of bed — and they are deliberately the only two shown: they're the put-down and the morning departure the night's times were actually derived from. A single camera can see that something crossed the edge of the bed, but not who, so movement in between is reported as exactly that and never attributed to a person.
  • Room temperature (optional). If a camera reports temperature/humidity over MQTT (set up under Settings → MQTT, e.g. via Zigbee2MQTT — the readings also show on the camera tile), the sleep detail overlays the night's room temperature beneath the timeline, aligned to the same time axis. After a handful of tracked nights, a "Sleep & room temperature" card compares wake-ups on the child's warmer vs cooler nights, so you can spot whether a warm room tends to mean more waking — a pattern, not a cause.

Adding caregivers

Once signed in as admin, go to Settings → Caregivers to create additional logins (e.g. for a partner or babysitter).

Capability Caregiver Admin
View live cameras and media Yes Yes
Reorder / assign cameras to a child Yes Yes
Restart, reboot, or snooze camera alerts Yes Yes
Add / edit / enable / delete a camera No Yes
Add / edit a child and review sleep Yes Yes
Delete a child and its media No Yes
Change global settings (detection, notifications, providers) No Yes
Manage caregiver/admin accounts and sessions No Yes

The Settings hub itself is visible to caregivers too — its admin-only pages (general, camera controls, recording, MQTT, push providers, users, logs, clip storage) are simply hidden for them rather than the whole screen being off-limits.

Changing someone’s role takes effect immediately — on their very next action, on every device they’re signed in on. Demoting an admin to caregiver does not sign them out: they keep browsing as a caregiver and simply lose the admin-only screens. Deleting an account, by contrast, ends its sessions at once and signs that person out everywhere.

Any account can also turn on two-factor authentication for its own login — see docs/mfa.md for enrolling, one-time backup codes, and how to recover if an admin loses their authenticator.

Running behind a reverse proxy (e.g. SWAG on Unraid)

A ready-to-use config is in reverse-proxy/nightlight.subdomain.conf. Copy it to swag/config/nginx/proxy-confs/nightlight.subdomain.conf and replace UNRAID_LAN_IP with the container's LAN IP — the host's IP in host mode, or the container's own dedicated IP if you gave it one (see Networking modes). Either way SWAG reaches it by that IP, not by container name.

macvlan note: if you put Nightlight on its own IP with plain macvlan, SWAG running on the same host won't be able to reach it (the host↔macvlan-container limitation noted above) — use ipvlan, or run SWAG on a different host.

Everything is proxied through a single port (4000): the app, login, all pages, and the video signaling handshake — no extra ports to open on your router for this part.

TRUST_PROXY — telling Nightlight your real client addresses

Variable Default What it does
TRUST_PROXY loopback Which upstream addresses may set X-Forwarded-For. Accepts an IP or CIDR (10.0.0.20, 172.18.0.0/16), a comma-separated list, a hop count (1), or a named range.

Behind a proxy, every request arrives from the proxy's address unless you tell Nightlight to trust it. The default (loopback) only trusts a proxy on 127.0.0.1 — and SWAG reaches Nightlight by its LAN IP, so with the setup above it is not trusted and every remote visitor looks like one client.

Set it to the address your proxy connects from:

-e TRUST_PROXY=10.0.0.20        # SWAG's own LAN IP

⚠️ Only set this to an address you control. Whatever you trust here is allowed to declare a client's IP. Too broad a value — or true, which trusts every upstream — lets anyone forge X-Forwarded-For and slip the login rate limit entirely, which is worse than leaving it unset. If you are not sure, leave it alone.

A value Nightlight can't make sense of is ignored, with a warning in the log, and the safe default is used instead — a typo here will never stop the app starting.

You do not have to set it. Login attempts are limited per account and per source, so even with every remote user sharing the proxy's address, one person mistyping their password cannot lock anyone else out. Setting TRUST_PROXY makes the per-source half meaningful as well.

Remote / internet access (watching from outside your home network)

By default this is LAN-only. There are two ways to watch remotely, and each camera tile has a toggle to switch between them ("Low latency" / "Compatibility"):

Low latency (WebRTC) — near-instant video, same as at home. This requires:

  1. Set up SWAG as described above (HTTPS for the app itself).
  2. Set PUBLIC_HOST to your public IP or a DDNS hostname.
  3. Forward UDP port 8189 on your router to your server's LAN IP.

This is a hard requirement of WebRTC, not a workaround — the actual audio/video always travels over UDP between your browser and MediaMTX, no matter what. A TURN relay server doesn't change this (it only changes how the signaling connects, not the media itself), so there's no way to get the low-latency mode down to zero UDP ports. This single UDP port forward is all you need, unless you're behind CGNAT.

Compatibility (HLS) — a few seconds of delay, but pure HTTP/TCP, so it rides through the same port 443 as everything else with no extra port forwarding at all. Use this if you'd rather not forward a UDP port, or if you're ever watching from a network that blocks outbound UDP (some corporate/public Wi-Fi).

iOS background audio: On iPhone/iPad, background listening (screen off / app minimised) works in Low latency mode only — its WebRTC audio lets Nightlight fully own the lock screen (camera name, artwork, Pause/Play). Compatibility is HLS, which iOS runs its own native lock-screen session for that we can't reliably control, so Background isn't offered for a Compatibility camera on iOS (it was tried and removed for being inconsistent — see KNOWN-ISSUES.md). Compatibility still works for live viewing. Android is unaffected — its background-listening service keeps both modes alive.

Both modes work automatically once SWAG + PUBLIC_HOST are set up — Compatibility mode needs nothing further, since it's already proxied through the app's normal port.

Installing to your home screen

The app has a web app manifest and icons for a full-screen, native-app-like home-screen experience — no browser address bar. It has no service worker, so this is an install shortcut, not an offline mode: the app still needs to reach your server over the network every time, same as opening it in a tab.

What you actually get depends on the platform and whether the site is served over HTTPS (via the reverse-proxy setup above) or plain http:// on your LAN:

Platform Over HTTPS Over plain http://
Android — Chrome / Samsung Internet Installable PWA (full-screen, own icon, install prompt) Usually a browser-badged shortcut that still opens inside the browser, not a true standalone install — Chromium's installability criteria require HTTPS (or localhost)
iOS — Safari Add to Home Screen (full-screen, own icon) Add to Home Screen works the same over plain HTTP — iOS doesn't gate it on HTTPS the way Chromium does
Desktop browser Installable as a windowed app (Chrome/Edge) Same shortcut limitation as Android Chrome
  • Android, HTTPS: browser menu → "Add to Home screen" / "Install app" (or the automatic install banner).
  • Android, plain HTTP: same menu item still adds something to your home screen, but expect a shortcut that opens in the browser rather than a standalone window.
  • iOS: Share button → "Add to Home Screen" — works the same either way.

If you want the full installed experience on Android specifically, the reverse-proxy/HTTPS setup above is worth doing; otherwise the native Android app (below) gives you a true standalone app without needing HTTPS at all.

Mobile apps (Android & iOS)

Beyond the PWA, there are companion native apps in a separate repo, nightlight-mobile. They're thin native shells around this same web UI — the app loads the interface live from your own server rather than bundling it, so any server update applies to the apps automatically with no reinstall. On first launch you just enter your server's address (like the Home Assistant app); a "Change server" menu item switches later.

What the native apps add over the browser/PWA:

  • Reliable background listening — keep hearing a camera with the screen off or the app minimised. Android uses a foreground service (with a wake/wifi lock and a battery- optimisation exemption); iOS uses a background audio session. Plain in-browser background audio is unreliable by comparison. On iOS this requires a camera in Low latency mode — Compatibility (HLS) can't do reliable background audio on iOS (iOS runs its own native lock-screen session for it), so Background is only offered for Low-latency cameras there; see the Low latency / Compatibility notes above and KNOWN-ISSUES.md.
  • Pause/Resume from the system controls — Android's notification and iOS's Now Playing (Control Center / lock screen), plus a Stop on Android.
  • Picture-in-Picture — float a camera in a small always-on-top window while you use other apps (Android; the browser has its own PiP too).
  • Battery-friendly — minimising the app disconnects streams unless a camera is in Background mode, so nothing keeps pulling video in the background.
  • Push notifications — a phone alert when a camera sees motion, even with the app closed (Android only for now). See below.

Push notifications (motion alerts)

Get a phone notification when a camera with motion detection sees movement — even when the app is closed. It's off by default, and the in-app Recent alerts list — on each child's page, or the combined view under Settings → Logs (admin only) — works with or without it. There are two ways to set it up (pick one), both configured under Settings → Push notifications:

Pushover (recommended — simplest, and works on iOS). A small notification service (the same one Sonarr/Radarr use). No Firebase project, no Apple Developer account.

  1. Install the Pushover app on your phone and note your User Key (or make a Delivery Group to alert several caregivers).
  2. Create a Pushover application at pushover.net/apps/build and copy its API Token.
  3. In Settings → Push notifications → Pushover, paste the token + your user/group key, Enable, Save (it verifies with Pushover), and Send test. Then enable Motion detection on a camera. Alerts arrive with a snapshot of what triggered them.

Firebase / FCM (Android app only). Delivers straight to the Nightlight Android app via your own Firebase project: drop google-services.json + a firebase-service-account.json service-account key into your data dir (e.g. /mnt/user/appdata/nightlight, keep the key chmod 600), then enable it under Settings → Push notifications. Each phone opts in under Account → Notifications.

Full walkthrough for both, with troubleshooting: docs/notifications.md.

Getting them:

  • Android — download the signed APK from the nightlight-mobile Releases page and sideload it (built and signed automatically in CI on each release).
  • iOS — no App Store build yet; it's installed by sideloading (e.g. AltStore/Sideloadly with a free Apple ID). See the nightlight-mobile repo for the current status and steps.

Both apps are built entirely in GitHub Actions — see the nightlight-mobile repo for how, and its own CHANGELOG.md for per-release notes.

Logs

Both the app and MediaMTX log to stdout, captured by Docker in the normal way:

docker logs -f nightlight

Docker's own log rotation (already configured in the docker run/Compose examples above, and in the Unraid template's extra parameters) caps total log storage at 10MB × 3 files, so this doesn't grow unbounded - this matters particularly on Unraid, where Docker's storage is a fixed-size image that can break the whole Docker service if it fills up. If you deployed before this was added, add --log-opt max-size=10m --log-opt max-file=3 yourself (or the Unraid template's "Extra Parameters" field) to get the same protection.

Timestamps use your container's local time (see the TZ variable above) rather than UTC, so they line up with when you actually remember something happening.

Troubleshooting

  • Camera shows "No signal": double check the RTSP URL works with a tool like VLC (Media → Open Network Stream) first — if VLC can't play it, the app won't either.
  • Video won't connect from a phone but the pages load fine: confirm the device is on the same LAN (for Low latency mode) — see "Remote / internet access" above if it's actually a different network.
  • A camera shows disconnected for a few seconds after opening the app and doesn't come back: this is usually a stale WebRTC connection on the phone itself, not the camera or server — the video connection can get "wedged" after the phone sleeps, switches networks, or hands off Wi-Fi/cellular, and won't always re-establish on its own. Pull down on the camera dashboard to reconnect — this works in the browser and in the mobile apps, and rebuilds the connection without a full restart. If a camera is actually down, every device sees it, not just one — check the Camera history panel (Settings) or the logs (below) to tell the two apart.
  • Background listening stops after a while (Android app): Android's battery optimisation (Doze) will eventually freeze the app and cut its network unless it's exempt. The app asks for the exemption the first time you enable Background mode — if you declined, grant it manually under Settings → Apps → Nightlight → Battery → Unrestricted. (Background audio is a feature of the native apps, not the browser/PWA — see Mobile apps.)
  • A camera says "No signal" only in Compatibility mode: usually the camera sending bad audio timestamps, which can stall the HLS pipeline. Nightlight resamples/rewrites camera timestamps to ride through this, but if it persists, power-cycle or update that camera's firmware. See KNOWN-ISSUES.md.
  • Checking whether MediaMTX has registered your cameras: its API is loopback-only (not reachable directly from a browser), so check it from inside the container:
    docker exec nightlight wget -qO- http://127.0.0.1:9997/v3/paths/list
    
    An empty "items":[] with cameras added in the app means MediaMTX and the app's database have drifted apart — restarting the container re-syncs them automatically (see the startup log line "Reconciled N camera path(s)...").
  • Checking logs: see the "Logs" section above — docker logs -f nightlight.

For a catalogue of understood quirks (camera glitches, WebRTC reconnects, the watchdog's recovery window) with what each one means and whether it needs any action, see KNOWN-ISSUES.md.

Building from source

git clone https://github.com/sauso/nightlight.git
cd nightlight
docker build -t nightlight .
docker run -d --name nightlight --network host --stop-timeout 30 \
  -e PUID=99 -e PGID=100 -v ./data:/app/data nightlight

Project layout

backend/          Express API + SQLite storage + process supervision for MediaMTX/FFmpeg
frontend/          React mobile-first UI (built into the image at build time)
mediamtx/          Default MediaMTX config (baked into the image - see src/index.js for why)
reverse-proxy/     Example SWAG config for running behind HTTPS
Dockerfile         Single combined image (app + MediaMTX + FFmpeg)
unraid-template.xml   Unraid Community Applications template
docker-compose.yml

License

MIT — see LICENSE.

Install Nightlight on Unraid in a few clicks.

Find Nightlight in Community Apps on your Unraid server, review the template, and click Install. Unraid handles the Docker app or plugin setup from the published template.

Open the Apps tab on your Unraid server Search Community Apps for Nightlight Review the template variables and paths Click Install

Download Statistics

43,783
Total Downloads

Related apps

Details

Repository
sauso/nightlight
Last Updated2026-09-19
First Seen2026-09-19

Runtime arguments

Web UI
http://[IP]:[PORT:4000]/
Network
host
Shell
sh
Privileged
false
Extra Params
--log-opt max-size=10m --log-opt max-file=3 --stop-timeout 30

Template configuration

WebUI PortPorttcp

Used ONLY to build the WebUI link above - with Host networking (the default), changing this number does NOT change the port the app actually listens on. To actually change the port, set the PORT variable below to the SAME number as this field.

Target
4000
Default
4000
Value
4000
PORTVariable

OPTIONAL - the port the app actually listens on. Leave blank for the default (4000). If you set this, also change WebUI Port above to the same number, or the WebUI link will point at the wrong port.

Data DirectoryPathrw

Stores the database and MediaMTX's config (including your added cameras)

Target
/app/data
Default
/mnt/user/appdata/nightlight
Value
/mnt/user/appdata/nightlight
Recordings Directory (optional)Pathrw

OPTIONAL - where event-recording clips are stored. Leave unset and clips go under the Data Directory (your SSD cache). To keep large clips off the SSD, map this to a folder on your array (e.g. /mnt/user/nightlight-clips) AND set the CLIPS_DIR variable below to /recordings. Retention caps (Settings - Recording) protect the disk either way.

Target
/recordings
CLIPS_DIRVariable

OPTIONAL - set to /recordings only if you mapped the Recordings Directory above to store clips on your array instead of under the Data Directory. Leave blank otherwise.

PUIDVariable

User ID that owns files this container creates in the Data Directory - 99 matches Unraid's own 'nobody' convention, so the default should already be correct

Default
99
Value
99
PGIDVariable

Group ID that owns files this container creates in the Data Directory - 100 matches Unraid's own 'users' convention, so the default should already be correct

Default
100
Value
100
TimezoneVariable

Your local timezone, e.g. Australia/Melbourne - affects log timestamps only

Target
TZ
Default
UTC
Value
UTC
JWT SecretVariable

Optional - leave blank and a secure random secret is generated and persisted in the data directory automatically on first run

Target
JWT_SECRET
Public HostVariable

Your public IP or DDNS hostname - only needed if you want Low Latency (WebRTC) mode to work from outside your home network (also requires forwarding UDP port 8189 on your router). Leave blank for LAN-only use.

Target
PUBLIC_HOST