Build a Simple CCTV-Style IP Camera with Raspberry Pi, Python & Flask

In this post, we’ll build a small “CCTV” system using:

  • Raspberry Pi + Camera
  • Python
  • Flask (for HTTP streaming)
  • Picamera2 + OpenCV
  • systemd (to run on boot)
  • Basic Auth (for simple password protection)
  • MP4 recording to disk

You’ll end up with:

  • A live IP camera stream you can open in any browser
  • Password-protected access
  • Automatic video recording to .mp4 files
  • The service starting automatically on boot

1. Install required packages

On your Raspberry Pi:

sudo apt update
sudo apt install -y \
  python3-pip \
  python3-flask \
  python3-picamera2 \
  python3-opencv

Make sure the camera works at OS level first:

rpicam-still -o test.jpg

If that saves an image, your camera stack is fine.


2. Create the streaming + recording server (Python)

Create a file, e.g. stream_server.py:

from flask import Flask, Response, request
from functools import wraps
from picamera2 import Picamera2
import cv2
import os
import time
import threading
from datetime import datetime

app = Flask(__name__)

# ========= PASSWORD PROTECTION =========
USERNAME = "piuser"       # change this
PASSWORD = "mypassword"   # change this

def check_auth(username, password):
    return username == USERNAME and password == PASSWORD

def authenticate():
    return Response(
        "Login required\n",
        401,
        {"WWW-Authenticate": 'Basic realm="Login Required"'}
    )

def requires_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth = request.authorization
        if not auth or not check_auth(auth.username, auth.password):
            return authenticate()
        return f(*args, **kwargs)
    return decorated


# ========= CAMERA + RECORDING SETUP =========
picam2 = Picamera2()
video_config = picam2.create_video_configuration(
    main={"size": (640, 480), "format": "BGR888"}  # BGR is natural for OpenCV
)
picam2.configure(video_config)
picam2.start()

os.makedirs("recordings", exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
video_path = f"recordings/record_{timestamp}.mp4"

fourcc = cv2.VideoWriter_fourcc(*"mp4v")
fps = 10.0
frame_size = (640, 480)

video_writer = cv2.VideoWriter(video_path, fourcc, fps, frame_size)

last_jpeg = None
frame_lock = threading.Lock()
running = True


def capture_loop():
    """Single loop that talks to the camera and writes recording + JPEG."""
    global last_jpeg, running

    while running:
        frame = picam2.capture_array()  # BGR888

        # Write to recording
        video_writer.write(frame)

        # Encode JPEG for streaming
        ret, buffer = cv2.imencode(".jpg", frame)
        if ret:
            jpg_bytes = buffer.tobytes()
            with frame_lock:
                last_jpeg = jpg_bytes

        # small sleep to avoid hammering CPU
        time.sleep(0.03)  # ~30 fps max


# Start the background capture thread
capture_thread = threading.Thread(target=capture_loop, daemon=True)
capture_thread.start()


def generate_frames():
    """Yield the latest JPEG over MJPEG."""
    global last_jpeg

    while True:
        with frame_lock:
            frame = last_jpeg

        if frame is not None:
            yield (
                b"--frame\r\n"
                b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n"
            )

        time.sleep(0.03)


# ========= ROUTES =========
@app.route("/video")
@requires_auth
def video():
    return Response(
        generate_frames(),
        mimetype="multipart/x-mixed-replace; boundary=frame"
    )

@app.route("/")
@requires_auth
def index():
    return (
        "<h1>Raspberry Pi Camera Stream</h1>"
        "<p>Go to <a href=\"/video\">/video</a> to see the live stream.</p>"
    )


def cleanup():
    """Release camera and writer cleanly."""
    global running
    running = False
    time.sleep(0.1)
    video_writer.release()
    picam2.stop()


if __name__ == "__main__":
    try:
        # threaded=False so Flask doesn’t spawn multiple request threads
        app.run(host="0.0.0.0", port=5000, debug=False, threaded=False)
    finally:
        cleanup()

Run it manually to test:

python3 stream_server.py

From another device on the same network:

  1. Find Pi IP: hostname -I
  2. Open in browser:
    • http://<pi-ip>:5000/ → info page (will ask for username/password)
    • http://<pi-ip>:5000/video → live MJPEG stream (also password-protected)

Recordings will be stored in:

~/recordings/record_YYYYMMDD_HHMMSS.mp4

3. Run it automatically on boot (systemd)

Put your script somewhere stable, e.g.:

/home/pi/stream_server.py

Create a systemd service:

sudo nano /etc/systemd/system/pi_cam_stream.service

Add:

[Unit]
Description=Raspberry Pi Camera Stream
After=network.target

[Service]
ExecStart=/usr/bin/python3 /home/pi/stream_server.py
WorkingDirectory=/home/pi
User=pi
Restart=always

[Install]
WantedBy=multi-user.target

Enable & start:

sudo systemctl daemon-reload
sudo systemctl enable pi_cam_stream.service
sudo systemctl start pi_cam_stream.service

Check status:

sudo systemctl status pi_cam_stream.service

Now your stream + recording:

  • starts automatically at boot
  • restarts if it crashes
  • is available at http://<pi-ip>:5000/ on your LAN

4. Notes & possible improvements

  • Security
    • Use strong username/password.
    • For internet access, prefer VPN (WireGuard, Tailscale) or SSH tunneling instead of directly exposing port 5000.
  • Storage
    • Add rotation (new file every X minutes/hours).
    • Add cleanup logic to delete oldest files when disk is nearly full.
  • Performance
    • Lower resolution (e.g. 480×360) for older boards like Raspberry Pi 3.
    • Tweak fps (e.g. 5–10 fps is usually enough for CCTV-style use).