How Helen Gets Made A making-of, not the live show Watch her live

Cameras · publishing a camera safely

Four cameras public. Eleven cameras in Frigate. Frigate never on the internet.

The live stills on helenthecatlive.com come from Frigate on a desktop in a house. Frigate has no public address, no port forward, and no account a visitor could guess at. What the public gets is a JPEG, from a list of four, through three things that each say no.

The chain is below, with the two config blocks that do the work; the Caddy and pm2 details are on the hosting page.

Helen at the water bowl, the frame the public Water Cam serves as a refreshing JPEG
The Water Cam, as the public sees it: one JPEG, no more than one new frame every 1.5 s, from an allowlist of four.

Most answers to "how do I share a Frigate camera on a website" involve opening a port, and a port forward to Frigate hands a stranger the admin console: every camera, every recording, the config editor. Frigate should be reachable from the LAN and from a private network and from nowhere else. What we built instead can be tested from your own browser: the four public frames are at the bottom, and any other camera name returns a refusal.

The rule

The public sees latest.jpg from an allowlist. Not a stream, not the Frigate UI, not the events list, not RTSP, not the house cameras, not the Face camera. Four names, each with a label: Water Cam, Food Cam, Treat Cam, Scratch Cam (the overhead we call the Court). That list lives in two places, and both have to agree before a frame goes out.

Eleven cameras exist in Frigate on the box. Seven of them watch Helen, three watch the house, and one is a spare, disabled. Which of them a visitor can see is decided on the web server, not in Frigate, so a mistake in Frigate's config cannot publish a doorway. The naming and the private/public split are laid out on the network-and-naming page.

The chain

One request for a Water Cam frame
browser
  GET https://helenthecatlive.com/live-cam/api/cam/cat_water_bowl/latest.jpg
  |
  v
Caddy (Helen VPS, 1 vCPU)            handle /live-cam/* { reverse_proxy 127.0.0.1:5178 }
  |
  v
server/api.mjs (node, pm2, :5178)    path must be one of LIVE_CAM_PATHS (4 entries)
  |                                  40 requests / 10 s per IP, else 429
  |                                  1.5 s memo per camera + in-flight dedupe
  |                                  adds header x-helen-code: <ACCESS_CODE>
  v
helen-cam-proxy (express, pm2, :3113, loopback only)
  |                                  access code, timing-safe compare; 12 bad codes / 10 min per IP -> 429
  |                                  GET only; camera must be in HELEN_CAMS, else 403
  v
Tailscale                            http://<TAILSCALE_IP>:5000/api/cat_water_bowl/latest.jpg
  |
  v
Frigate (Windows desktop in the house, WSL2 docker)   no public IP, no port forward

Three separate refusals, in three separate processes, written at three separate times. The site backend does not know Frigate's address. The proxy does not know which site is calling it, only whether the code is right. Frigate does not know either of them exists. Widening one list does not widen the other: to make the Face camera public you would have to add it to HELEN_CAMS on the proxy and to LIVE_CAM_PATHS in the backend, in two deploys, and that friction is the feature.

What each layer is responsible for
LayerSays no when
CaddyThe path is not /live-cam/* or /api/*; everything else is a static file. The proxy's own port is not in any Caddy block, so it cannot be reached by name.
Site backend (api.mjs)The path is not one of the four (404 "Unknown camera"); the IP has made 40 requests in 10 s (429 "Camera is catching up"); the proxy did not answer in 2.5 s (502 "Camera feed unavailable"). It also collapses a burst of identical requests into one upstream call.
Cam proxy (helen-cam-proxy)No or wrong x-helen-code (401; 12 wrong codes in 10 min gets the IP a 429); the method is not GET; the camera is not in HELEN_CAMS (403); an event id belongs to a camera that is not on the list (checked before any thumbnail or clip is served). Only /health is open.
TailscaleThe caller is not on the tailnet. The Frigate box's :5000 and :8554 are bound on a machine with no inbound route from the internet.

The site-side handler, as it runs

This is the part of server/api.mjs that serves the four public frames. Plain node:http, no framework. The proxy in front of Frigate is a separate small Express app whose behaviour is in the table above; its env contract is three lines.

server/api.mjs — the allowlist and the memoised upstream call
const LIVE_CAM_PATHS = new Set([
  "/api/cam/cat_water_bowl/latest.jpg",
  "/api/cam/cat_food_bowl/latest.jpg",
  "/api/cam/cat_treat_dispenser/latest.jpg",
  "/api/cam/cat_scratching_pad/latest.jpg",
]);

const camCache = new Map();
const camInflight = new Map();

const CAM_PROXY_BASE = (process.env.HELEN_CAM_PROXY_BASE || "http://127.0.0.1:3113").replace(/\/+$/, "");
const CAM_PROXY_CODE = process.env.HELEN_CAM_PROXY_CODE || "";

async function fetchLiveCam(camPath) {
  const hit = camCache.get(camPath);
  if (hit && Date.now() - hit.at < 1500) return hit;
  if (camInflight.has(camPath)) return camInflight.get(camPath);
  const job = (async () => {
    // camPath looks like /api/cam/<cam>/latest.jpg; the local cam proxy
    // (helen-cam-proxy, mirroring Frigate) serves it as /api/<cam>/latest.jpg.
    const upstreamPath = camPath.replace(/^\/api\/cam\//, "/api/");
    const up = await fetch(`${CAM_PROXY_BASE}${upstreamPath}`, {
      headers: CAM_PROXY_CODE ? { "x-helen-code": CAM_PROXY_CODE } : {},
      signal: AbortSignal.timeout(2500),
    });
    const rec = {
      status: up.status,
      type: up.headers.get("content-type") || "image/jpeg",
      buf: Buffer.from(await up.arrayBuffer()),
      at: Date.now(),
    };
    camCache.set(camPath, rec);
    return rec;
  })();
  camInflight.set(camPath, job);
  try {
    return await job;
  } finally {
    camInflight.delete(camPath);
  }
}
server/api.mjs — the route
    if (url.pathname.startsWith("/live-cam/")) {
      const camPath = url.pathname.replace(/^\/live-cam/, "");
      if (!LIVE_CAM_PATHS.has(camPath)) {
        res.writeHead(404, { "Content-Type": "application/json", ...SEC_HEADERS });
        res.end(JSON.stringify({ message: "Unknown camera." }));
        return;
      }
      if (!rateLimit(`cam:${clientIp(req)}`, 40, 10_000)) {
        res.writeHead(429, { "Content-Type": "application/json", ...SEC_HEADERS, "Retry-After": "2" });
        res.end(JSON.stringify({ message: "Camera is catching up." }));
        return;
      }
      try {
        const rec = await fetchLiveCam(camPath);
        res.writeHead(rec.status, {
          "Content-Type": rec.type,
          "Cache-Control": "private, max-age=1",
          ...SEC_HEADERS,
        });
        res.end(rec.buf);
      } catch {
        res.writeHead(502, { "Content-Type": "application/json", ...SEC_HEADERS });
        res.end(JSON.stringify({ message: "Camera feed unavailable." }));
      }
      return;
    }
The proxy's whole configuration (pm2 env)
FRIGATE_BASE=http://<TAILSCALE_IP>:5000
HELEN_CAMS=cat_water_bowl:Water Cam,cat_food_bowl:Food Cam,cat_treat_dispenser:Treat Cam,cat_scratching_pad:Scratch Cam
HELEN_ACCESS_CODES=<ACCESS_CODE>
/etc/caddy/sites/helenthecatlive.caddy — the only two lines that reach the backend
	handle /api/* {
		reverse_proxy 127.0.0.1:5178
	}
	handle /live-cam/* {
		reverse_proxy 127.0.0.1:5178
	}

The 1.5-second memo is what makes a one-vCPU VPS enough. However many people have the court open, Frigate is asked for a given camera at most once every 1.5 s, and a burst of requests that arrive while that fetch is in flight all get the same answer. The VPS never touches video; it moves a JPEG of a few tens of kilobytes, and the machine that does the real work is the desktop in the house. The Android app the proxy was originally built for uses the same code and the same allowlist.

Why this and not the obvious things

  • Not a port forward. Frigate's web UI is an admin surface, whatever login sits in front of it. A port forward also puts the house's public IP in every page source. Tailscale gives the VPS a route to one machine and nothing else, and the house has no inbound port at all. Tailscale's own notes on subnet routers cover the case where the NVR cannot run the client itself.
  • Not go2rtc's WebRTC. It is excellent on a LAN and it is the right answer for a private viewer app. For a public page it means a media server on the internet, STUN/TURN, and per-viewer cost that scales with the audience. A still that refreshes is what a website can afford forever, and the 24/7 video lives where video belongs: on YouTube.
  • Not one allowlist. Two, because the day you are debugging at midnight and add a camera to "just see if it works" should not be the day it becomes public.
  • Not Frigate's own auth. The proxy's access code is not a login; it is a shared secret between two processes on the same VPS, in an env file, and it exists so that even a bug in Caddy that exposed :3113 would produce a 401, not a picture.
  1. Write the allowlist first. Camera id and public label, one line. If you cannot say why a camera should be public, it is not. Ours is four of eleven; the Face camera that runs 24/7 on YouTube is not on the website, where a curated still stands in for it.
  2. Put the web server and the Frigate box on one private network. Tailscale on both. Confirm from the VPS that curl http://<TAILSCALE_IP>:5000/api/version answers and that the same request to the box's public address does not.
  3. Run a read-only proxy on the VPS, on loopback. A small Express app bound to 127.0.0.1, started by pm2, configured entirely from the three env lines above. GET only. A short allowlist of Frigate paths: /api/version, /api/events (with the camera filter forced into the allowlist), event thumbnails and clips (after checking which camera the event belongs to), and /api/<cam>/latest.jpg. Everything else 404.
  4. Have the site backend call the proxy, never Frigate. The backend knows HELEN_CAM_PROXY_BASE and HELEN_CAM_PROXY_CODE. It does not know a Tailscale address. It memoises for 1.5 s, dedupes in-flight calls, and rate-limits per IP.
  5. Let Caddy expose only that path. handle /live-cam/* { reverse_proxy 127.0.0.1:5178 }. Give the JPEG Cache-Control: private, max-age=1 so a browser refreshing every second does not get a stale frame and a shared cache never stores one. The rest of the block is on the hosting page.
  6. Test the refusals, not just the pictures. From outside: /live-cam/api/cam/bed_cam/latest.jpg must be a 404 from the site; curl -H "x-helen-code: wrong" against the proxy from the VPS itself must be a 401; the Frigate box's public address on :5000 must time out. If any of those returns a picture, stop and fix it before the site goes up.
  7. Keep crawlers off it. Disallow: /live-cam/ in the show site's robots.txt (ours has had it since 2026-09-03). The proxy's own path on this domain is linked from nowhere and answers 401 without the code, so we left it out of robots.txt rather than advertise it there. A JPEG that changes every second is not a page, and Search Console will otherwise report it as an error forever.

What you give up

Motion. A visitor sees a new frame roughly every 1.5 seconds and never hears the fountain. On the 3D court that is fine, because the frames are textures on screens in a room you can walk around, and the room is the show; on a plain page it is a slideshow. For live video there is the mosaic and the Face cam on YouTube, encoded from the same go2rtc streams, and that trade (stills on the site, video on the platform built for it) is what lets the whole web tier, both sites and their analytics, run on one 1-vCPU VPS.