> For the complete documentation index, see [llms.txt](https://docs.eyepop.ai/developer-documentation/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.eyepop.ai/developer-documentation/deploying/cloud/private-cameras.md).

# Private Cameras

Analyze cameras behind a firewall with EyePop cloud inference

Use this path when cameras stay on a private network but inference runs in EyePop cloud. It is different from an on-premise runtime, which runs inference inside your infrastructure.

### Choose an integration

| Pattern            | Video path                                                        | Who operates the on-premise component | Best fit                                  |
| ------------------ | ----------------------------------------------------------------- | ------------------------------------- | ----------------------------------------- |
| VMS plugin         | The plugin pushes MPEG-TS over HTTPS                              | Customer                              | A VMS with a supported plugin runtime     |
| Customer connector | A customer service pushes MPEG-TS over HTTPS                      | Customer                              | A custom integration without a VMS plugin |
| Camera relay       | Your application reads RTSP locally and pushes MPEG-TS over HTTPS | Customer                              | An RTSP camera, without running a VPN     |
| Managed Tailscale  | The cloud worker reads RTSP across the tailnet                    | Customer                              | A managed, private network integration    |

No pattern requires an inbound firewall port: video is either pushed outbound from inside the network, or carried over a mutually authenticated VPN overlay. With cloud inference, encoded video leaves the premises over TLS or WireGuard. Choose an on-premise runtime if video must be processed locally.

### Connect a camera on a private network

A camera on a private LAN has no route in from outside it, and closing that gap is usually the hardest part of the integration. Port forwarding exposes the camera to the internet. A hand-rolled VPN or reverse tunnel works, but it is yours to run and keep up. On-premise installs hit a third problem: a container on a Docker bridge network cannot reach a LAN camera without host networking or an explicit route.

[Tailscale](https://tailscale.com) is the shortest way through. Install it on the camera's network and on whatever reads the stream, and both sides sit on one tailnet — a stable address, no inbound firewall rule, no port forwarding.

Setting up the tailnet is yours to do today; the CLI does not configure it for you. For the managed pattern above, where an EyePop cloud worker reads RTSP across your tailnet, contact <support@eyepop.ai>.

### Relay a camera instead of exposing it

A tailnet is not the only way through, and it is not the shortest. Your application can read the camera on your own network and forward the stream to EyePop over the same outbound HTTPS every other push pattern uses. Nothing has to be reachable from the internet, there is no VPN to operate, and the camera's credentials never leave your network.

The relay **remuxes** the stream — it copies the encoded video into MPEG-TS without decoding or re-encoding it. That is what keeps the cost negligible and the quality identical to the camera's own output; a relay is not a transcoder and does not need a GPU.

#### Relaying with the Python SDK

Relaying needs the `relay` extra, which pins the media library used to read the camera. Requires `eyepop` 3.21.1 or newer:

```bash
pip install 'eyepop[relay]'
```

`rtsp_relay_stream()` opens the camera and hands you its video as a stream of bytes, with the capture times already travelling inside it. Pass that stream to the worker the same way you would any other upload:

```python
import asyncio

from eyepop import EyePopSdk
from eyepop.relay import rtsp_relay_stream
from eyepop.worker.worker_types import InferenceComponent, Pop, VideoMode


async def main():
    async with EyePopSdk.async_worker() as endpoint:
        await endpoint.set_pop(Pop(components=[
            InferenceComponent(ability="eyepop.person:latest"),
        ]))

        stream = await rtsp_relay_stream("rtsp://user:password@camera.local/stream1")
        job = await endpoint.upload_stream(
            stream,
            mime_type="video/mpegts",
            is_live=True,
            video_mode=VideoMode.STREAM,
        )

        while (prediction := await job.predict()) is not None:
            print(prediction.get("captured_at"), prediction.get("objects"))

        if stream.failure:
            raise stream.failure


asyncio.run(main())
```

The camera is read on your network and its stream forwarded to the worker; predictions come back as they would from any other source, each carrying `captured_at`.

**Errors and retrying are yours to handle.** One call relays one RTSP session, and the SDK does not retry on your behalf — a decision rather than an omission, because a reconnect has to become a *new upload*: a single MPEG-TS stream cannot carry two RTSP sessions without renumbering the timestamps the capture times depend on.

So a camera that reboots, loses power, or sits behind a switch that restarts will end the stream, and your application decides what happens next. Three things are worth getting right:

* **Find out why the stream ended.** A live camera's stream does not finish on its own, so reaching the end of it means something went wrong — but a camera that simply stops sending looks identical to a file running out, and by the time the failure reaches your prediction loop it may be wrapped in a transport error. Check `stream.failure` once the stream ends. A `CameraError` is worth retrying; an `UploadError` is a question for EyePop and retrying it will usually just hammer a worker that already refused you.
* **Back off between attempts.** A camera rebooting takes tens of seconds. Retrying immediately fills your logs and achieves nothing; doubling the delay up to a ceiling of half a minute or so is the usual shape.
* **Expect a fresh start each time.** Each attempt is a new session and a new upload, and the capture-time window described below begins again with it.

`examples/relay_example.py` in [eyepop-sdk-python](https://github.com/eyepop-ai/eyepop-sdk-python) shows all three in about forty lines, as `relay_rtsp_source()`. Copy it if it suits you, or write your own policy around `rtsp_relay_stream()`.

A finite RTSP source — a recording served over RTSP rather than a live camera — needs no retry policy at all: it ends when it ends, and `stream.failure` tells you whether that was the end of the recording or a fault.

Relaying is available in the Python SDK today. There is no Node equivalent yet.

Trade-offs against the tailnet pattern:

|                        | Camera relay                     | Managed Tailscale              |
| ---------------------- | -------------------------------- | ------------------------------ |
| What you run           | A small application reading RTSP | A tailnet node, kept up        |
| Who reaches the camera | Only your application            | The EyePop cloud worker        |
| Reconnect and retry    | Yours                            | Yours                          |
| Setup                  | An SDK call                      | Coordinate with EyePop support |

### Capture time on a relayed camera

Predictions from a relayed camera carry `captured_at`: the moment the **camera** captured the frame, on the camera's own clock, rather than the moment EyePop received it. This matters whenever predictions are correlated with anything outside the stream — another camera, a sensor, a transaction log — because network and buffering delay between the camera and EyePop is variable and would otherwise be baked into every timestamp.

It is the same value, with the same meaning, that a worker reading the camera directly would report.

**What it requires of your camera.** The camera must emit RTCP sender reports and have a disciplined clock, usually from NTP. This is the ordinary case for business and industrial cameras and is often a setting you must switch on. Point the camera at the same time source as the rest of your infrastructure.

**When it is missing.** `captured_at` is left out of a prediction rather than sent as null:

* **At the start of a stream, if your camera is slow to send its clock reference.** Any frame forwarded ahead of that reference carries no capture time. In practice the relay rarely forwards one: opening the camera and waiting for the first complete frame takes around two seconds by itself, and most cameras send their reference within that window. Measured against a test camera, every prediction carried a capture time from the first one — the opening moments are not analysed at all, rather than analysed without a timestamp. This is not a cost of relaying: a worker reading the camera directly starts sooner, and so reports its own opening second or so without capture times. A reconnect starts a fresh session, where the same applies.
* **For the whole stream, if the camera has no usable clock.** Some cameras send no sender reports at all. The relay warns when it sees this, so the cause is visible rather than silent. No amount of configuration on the EyePop side changes it — it is a property of the camera.

A missing `captured_at` is never a guessed one. Where the capture time is not known, it is absent.

### Integration requirements

* One persistent session per camera is the recommended default for failure isolation.
* Push integrations send H.264 in MPEG-TS (`video/mp2t`) and receive JSONL predictions.
* The customer owns prediction storage, reconnect and retry, and operation of the on-premise component in every pattern.
* Confirm network egress, bandwidth, retention, region, and tailnet ACL requirements during deployment design.

### Next steps

* [Deployment](/developer-documentation/deploying/cloud/deployment.md) — the persistent session a camera stream attaches to
* [Source Types](/developer-documentation/platform/sources-and-options/sources.md#relayed-cameras) — the relay as a source, and the reference implementation to copy
* **On-Premise** — open the On-Premise section in this site's navigation to run inference locally instead of sending video out
