18 KiB
Camera Station
A self-hosted page for watching network cameras, recording them to a file, and taking photos from the live picture. It handles both MJPEG feeds (MJPG-Streamer and similar) and RTSP cameras.
Two cameras are configured out of the box:
| Camera | Type | Address | |
|---|---|---|---|
| 1 | Camera 1 | MJPEG | 192.168.69.79:8080 |
| 2 | Camera 2 | RTSP | 192.168.69.128:554 |
Run it with Docker
docker compose up -d
Then open http://localhost:8000. That is the whole install: the image brings its own ffmpeg, and the container restarts by itself after a crash or a reboot, so it behaves as a service rather than something you have to remember to start.
Everything it produces lands in ./data beside the compose file:
data/cameras.json your cameras
data/recordings/<camera>/ footage, in timestamped pieces
data/photos/ photos, named date_time_camera
That folder is the thing worth backing up; the image can always be rebuilt.
Useful commands:
docker compose logs -f # watch it
docker compose up -d --build # rebuild after editing any of the files
docker compose restart # after changing settings
docker compose down # stop; recordings are kept
The image is built from the Dockerfile here — there is nothing to download from a
registry, so all six files need to be in the same folder before you start:
Dockerfile docker-compose.yml .dockerignore
server.py index.html config.html library.html
If you see pull access denied for camera-station, Compose is trying to fetch the
image instead of building it. Run docker compose up -d --build once. (Earlier
versions of this compose file named the image, which invited exactly that; the current
one does not.)
Set TZ in docker-compose.yml to your own zone before you start, because photos and
recordings are named with local time.
Stopping is given 20 seconds so ffmpeg can close the file it is writing.
Run it without Docker
Keep the three files in the same folder, then:
python3 server.py
Open http://localhost:8000. Python 3.7+ and no pip install.
ffmpeg is required for the RTSP camera and only for that; the MJPEG camera needs
nothing. If ffmpeg is missing, camera 2 appears greyed out with the reason on screen
and camera 1 carries on working. Install it with apt install ffmpeg,
brew install ffmpeg, or from ffmpeg.org.
Configure cameras yourself with --camera "Name=url", repeated as many times as you
like. Anything starting rtsp:// is decoded automatically:
python3 server.py \
--camera "Workshop=http://192.168.69.79:8080" \
--camera "Driveway=rtsp://admin:secret@192.168.69.128:554/cam/realmonitor?channel=1&subtype=0"
python3 server.py --host 127.0.0.1 # only this machine can connect
Names you give here are what appear on the tabs and in saved filenames.
Using it
| Action | Control | Key |
|---|---|---|
| Switch camera | Camera tabs | 1 2 |
| Start / stop recording on the server | Record button | R |
| Take a photo, saved on the server | Take photo button | Space |
| Zoom in / out | Scroll, pinch, or the +/− buttons | + − |
| Pan while zoomed | Drag the picture | arrow keys |
| Back to the whole frame | Fit button, or double-click | 0 |
| Switch camera | Tabs above the picture | 1 2 |
Each camera keeps its own zoom, pan and frame-rate history, so switching back and forth doesn't disturb how you had either one framed.
A camera keeps recording while you watch another one. Its tab shows a red marker so you can see it is still running. The tally strip and the readouts always describe the camera you are currently looking at.
The page only holds open the feeds it actually needs — the one on screen, plus any that are recording. Switching away from an RTSP camera lets the server shut down its ffmpeg process instead of decoding video nobody is watching. The header carries a live clock. Below the picture, the frame rate cell shows the current rate plus a meter of the last 60 seconds — one bar per second, auto-scaled, with dim bars for seconds that fell well short of the usual rate and red bars for seconds where nothing arrived at all. It makes a camera that stutters under load obvious at a glance, which a single number does not.
Stamp time draws the date and time into the bottom corner of the picture itself, so it is baked into photos and recordings rather than just shown on the page. It's off by default. The stamp is drawn after each frame is checked for changes, so a ticking clock never gets mistaken for a live picture — freeze the camera with the stamp on and the meter still correctly drops to zero.
Photos download immediately and also stay in the roll at the bottom of the page so you can save one again. The PNG/JPEG toggle sets the format.
Recording
Each camera has a Recording / Not recording button on the set-up page, and the Record button in the viewer does the same thing. When a camera is recording:
- It is recorded continuously, on the server, whether or not any browser is open. Closing the page changes nothing.
- The stream is copied, not re-encoded. What lands on disk is exactly the bitstream the camera sent — no quality lost, and very little CPU used. Decoding is only needed to watch a camera, not to record it.
- Footage is cut into 10-minute pieces named
YYYYMMDD-HHMMSS.mkv, so old material can be removed a piece at a time.
Matroska (.mkv) is used rather than MP4 on purpose: a file that is still being
written stays playable if the machine loses power, where an unfinalised MP4 would be
lost. VLC, ffmpeg and most players open them; Windows Media Player does not.
If a camera drops off the network its recorder stops with it, and is started again automatically once the camera answers.
The size limit
10 GB of footage is kept. Once the total passes that, the oldest pieces are deleted until it is back under, so storage never grows without bound. The piece currently being written is never deleted, which is why usage can sit a little above the limit for a few minutes.
The limit is the total across all cameras, not per camera. Change it in
docker-compose.yml, or with --storage-limit 25 for 25 GB. Photos are not counted
against it — they are tiny by comparison — but the set-up page shows what they use.
The set-up page shows how full the store is; the Files page lists everything and lets you download or delete individual items.
How long it has been running
The recording timecode and the uptime figure both count in days, hours, minutes and
seconds — 3d 04:05:06. The day marker only appears once there is a day to show, so
a short recording still reads plainly as 00:04:12.
This matters more than it sounds for a service that is meant to be left alone: counting only minutes and seconds would quietly wrap back to zero every hour, and a camera that had been recording for a week would look like it had just started.
Photos
Take photo saves straight to the server. Nothing is downloaded and nothing asks where to put it. Files are named:
20260721_143052_Driveway.png date_time_cameraname
Two photos inside the same second get -2, -3 and so on rather than overwriting
each other.
What is saved is exactly what you are framing: if you have zoomed in, the photo is the cropped region at its own pixel count, cut from the original frame with no resampling. The roll under the picture lists what has been saved and links to each file if you also want a copy on the machine you are sitting at.
Browsing what has been saved
http://localhost:8000/files, or Files from the viewer.
Two tabs. Recordings lists every stored segment newest first, grouped by day, with the camera it came from and its size; Photos shows a thumbnail grid. Both can be filtered by camera and by date, and every entry has a Download link.
The segment a camera is writing into right now is marked recording now. You can still download it — you will get it as far as it had got — and its Download button says Download part so that is not a surprise. It is also the one file the delete button refuses to remove, since deleting it would not stop the recording, only lose the footage being captured.
Selecting files and pressing Delete selected removes them for good, after a confirmation. This is the same store the 10 GB limit applies to, so deleting by hand simply buys time before the automatic clean-up needs to.
Photos open in a tab when clicked. Recordings do not: they hold the camera's own stream in a Matroska container, which browsers will not play. Download them and use VLC, or convert one without re-encoding:
ffmpeg -i 20260721-143000.mkv -c copy 20260721-143000.mp4
Adding and editing cameras
Open http://localhost:8000/config, or follow Set up cameras from the viewer.
Add as many cameras as you like. The address decides how each one is handled: a
http:// address is treated as an MJPEG feed and relayed untouched, while an
rtsp:// address is decoded by ffmpeg on this machine.
Test pulls a single frame from a camera and reports its resolution, so you can check an address before you commit to it. It reports the real reason when it fails too — wrong password, connection refused, nothing listening.
Cameras are saved to cameras.json next to server.py and are picked up the next
time the server starts. The viewer notices changes within a few seconds, so you do
not need to reload it — except while a recording is running, when the update politely
waits until the recording stops rather than interrupting it.
Editing one camera leaves the others alone. Renaming does not restart anything, and a camera whose address is unchanged keeps streaming without a flicker.
Passwords are write-only
The set-up page shows a saved password as bullets and never receives the real one. Leave the bullets alone to keep the existing password; type over them to set a new one. The bullets are never written to disk, so an edit that never saw a password cannot destroy it.
Read-only mode
python3 server.py --lock-config
The set-up page still lists the cameras, but saving is refused. Worth using if the
page is reachable by anyone you would not hand the camera passwords to — note that
--lock-config only stops edits, it does not add a login.
Cameras named with --camera on the command line win for that run and are not
written to cameras.json.
Previews on the tabs
Each camera tab carries a small live preview.
The camera you are watching updates continuously, drawn from the frame already on screen, so it costs nothing. Cameras you are not watching are not connected, so their preview is a still that refreshes on a timer.
That refresh is not free for an RTSP camera — the server has to run ffmpeg briefly to produce each still — so the rate is yours to choose with the Previews button: 5s, 15s (the default), 60s, or off. The choice is remembered per browser, so a phone and a desktop can disagree about it. Set it to off and the previews simply freeze at the last picture each camera showed.
Cameras that came set up
Camera 1 is the MJPEG feed at 192.168.69.79. Camera 2 is the RTSP camera at
192.168.69.128, already wired to your address. Add or change them on the set-up
page; these are only the starting point.
Each camera keeps its own zoom, pan and frame history. Zoom into one, switch away, switch back, and it is still framed exactly where you left it.
The page connects only to the camera you are looking at. Switching away from an RTSP camera lets the server stop decoding it — recording is unaffected, because that runs separately and does not decode anything.
RTSP needs ffmpeg
No browser can play RTSP, so the server decodes it with ffmpeg and hands the page
MJPEG. MJPEG cameras need nothing extra. The Docker image already contains ffmpeg;
if you are running server.py directly you will need it installed:
winget install ffmpeg # Windows
sudo apt install ffmpeg # Debian/Ubuntu
brew install ffmpeg # macOS
Without it, RTSP cameras appear disabled with the reason on screen, and MJPEG cameras carry on working.
Your camera password never reaches the browser. It stays on the server. The page
is told only rtsp · 192.168.69.128:554.
Zoom, and what it does to quality
The camera sends one fixed resolution, so zoom here is a crop rather than a lens. The page is built so that cropping never costs anything it doesn't have to:
- Every frame is kept in a master canvas at the camera's native resolution, untouched and unstamped. The view, photos and recordings are each drawn from it directly, so no output is ever built from an already-resampled picture.
- The on-screen canvas is backed at your display's real pixel density, so zooming in reveals detail that was always in the stream but too small to make out.
- Crops are snapped to whole source pixels. A fractional crop boundary would force the browser to resample even at 1:1.
- A zoomed photo is a pixel-exact cut of the original. Zoom 2x into a 1280x720 feed and you get a 640x360 file whose pixels are identical to that region of the full frame, rather than a 1280x720 file padded out with interpolated detail. Smaller file, no invented information. The overlay always shows the exact pixel size you will get.
- The overlay also reports how hard the picture is being magnified to fill the viewport
(
screen 3.4x). A modest camera on a large monitor is magnified even at 1x, so this is stated plainly: you can tell when you are looking at real detail and when you are not.
Watching costs a re-encode. Recording does not.
These are two different paths, and it is worth being clear about which is which.
Recording is lossless. The stream is copied to disk exactly as the camera sent it, H.265 and all. Nothing is decoded, nothing is re-encoded, nothing is scaled. This is also why an active camera barely registers on the CPU.
Watching an RTSP camera costs one re-encode. No browser can play RTSP, so ffmpeg
decodes it and hands the page JPEG frames, at -q:v 3 and full resolution. That
re-encode is unavoidable for anything you want to see in a browser — but it only
happens while somebody is actually looking.
So the expensive thing is watching, not recording, and it stops when you close the page. If watching costs more CPU than you would like:
python3 server.py --rtsp-quality 6 # smaller JPEGs
python3 server.py --rtsp-fps 10 # fewer frames
python3 server.py --rtsp-size 1280x720 # smaller picture
Switching the camera URL to subtype=1 uses the camera's own substream and is usually
the cheapest option of all. It affects what gets recorded too, so use it only if the
substream is good enough to keep.
Recorded footage ignores zoom entirely. Zoom is a viewing control; what reaches the disk is always the camera's full frame. Zoom and pan as much as you like while a camera is recording without touching what is saved.
To turn a recording into MP4 without re-encoding it:
ffmpeg -i 20260721-143000.mkv -c copy 20260721-143000.mp4
Credentials
RTSP URLs usually carry a username and password. Those stay in the server process:
the page is told only rtsp · 192.168.69.128:554, and the password appears in no
response the browser ever receives. Passwords containing !, @ or other awkward
characters are safe — nothing is passed through a shell.
Anyone who can reach the page can watch the cameras, so bind to 127.0.0.1 or keep
it behind your LAN if that matters.
Why there's a server
A browser blocks reading pixels back out of a canvas that has drawn an image from
another origin. That would break both photo capture and recording. server.py serves
the page and relays the camera feed on the same origin, so the canvas stays readable.
The relay is also the only thing that talks to the camera — the browser never
connects to port 8080 directly.
Notes
- With
--host 0.0.0.0(the default) anyone on your LAN who finds the port can watch. There is no password. Use--host 127.0.0.1, or put it behind your reverse proxy, if that matters. - Recording captures each frame as it arrives, so the video runs at the camera's own rate rather than a fixed one. Cameras faster than your monitor's refresh rate will drop frames; anything up to 30 fps is captured 1:1.
- Photos are taken from the frame already on screen, so they never interrupt the feed.
- If the camera drops out, the page says so and keeps retrying on its own.
Running it in the background without Docker
Docker already does this. If you would rather not use it:
Linux (systemd)
# /etc/systemd/system/camera-station.service
[Unit]
Description=Camera Station
After=network-online.target
[Service]
ExecStart=/usr/bin/python3 /opt/camera-station/server.py --host 0.0.0.0
WorkingDirectory=/opt/camera-station
Environment=CAMERA_STATION_DATA=/var/lib/camera-station
Restart=always
RestartSec=5
KillSignal=SIGINT
TimeoutStopSec=20
User=youruser
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now camera-station
KillSignal=SIGINT and the stop timeout matter: they let ffmpeg close the file it is
writing rather than having it cut off.
Windows
Run it as a scheduled task set to Run whether user is logged on or not, triggered
At startup, with pythonw.exe so no console window appears:
Program: C:\Path\To\pythonw.exe
Arguments: server.py --host 0.0.0.0
Start in: C:\Users\JV\Desktop\files