Difficulty: Advanced  ·  July 2026

Setting Up Roost: A Self-Hosted Camera Pipeline

A step-by-step guide to running your own Raspberry Pi security camera with a Rust capture app, private S3 storage, AI recognition that tags every photo, and a browser dashboard you can search by what's actually in the frame.

Roost is a camera pipeline you own end to end. The Roost device (Raspberry Pi) captures from a USB camera, a Rust app exposes control over WebSocket, a Python script pushes snapshots to a private S3 bucket, a Lambda runs Claude vision on each new photo to describe and tag it, and a dashboard pulls it all together with live controls and search-by-contents. This guide walks through setting it up from a blank SD card to a working, searchable dashboard.

Rust · v4l
Capture app

Runs on the Roost device. Owns the camera, streams MJPG, takes commands over WebSocket.

Python · boto3
S3 uploader

Runs on the Roost device. Watches the output folder, uploads new frames, deletes the local copy.

Lambda · Claude
Vision recognition

Fires on each new photo. Claude describes and tags it; results go to DynamoDB.

React · Flask
Dashboard

Runs anywhere. Controls the camera, shows photos, searches by what's in them.

00 What You'll Need

A Raspberry Pi (5 recommended, 1GB is plenty)
A microSD card with adapter and a USB power supply
A USB camera that speaks UVC / V4L2
An Ethernet cable (this guide is headless)
An AWS account
An Anthropic API key (for recognition)
A computer to SSH from and run the dashboard
Comfort in a terminal
An evening, maybe two

Get the code first. Everything below assumes you have the repo handy:

github.com/BottleBlueLLC/Roost

01 Flash the Device and SSH In

Use an adapter and insert the microSD card into your computer. Navigate to the official Raspberry Pi Imager. Select Raspberry Pi OS (other) then choose Raspberry Pi OS Lite (64-bit); you don't need a desktop for a headless camera. Set a hostname (this guide uses roost), create the roost user with a password, and enable SSH with password authentication unless you have already configured access keys. Finally, click write. This bakes everything in so you never need a monitor or keyboard.

Once complete, remove the card from your computer and insert it into the Roost device, connect Ethernet, and power on. Give it a minute to boot, then open a terminal (not command prompt) and connect from your computer:

ssh roost@roost.lan

Type yes and hit enter to add to known hosts. If roost.lan doesn't resolve, use the Roost device's IP address instead (check your router's device list).

02 Confirm the Camera Is Seen

Install the V4L utilities (it may already be installed) and list video devices:

sudo apt update
sudo apt install -y v4l-utils
v4l2-ctl --list-devices

You should see your camera with one or more /dev/videoN nodes. Now check what formats and resolutions it actually supports:

v4l2-ctl --device=/dev/video0 --list-formats-ext
Why this matters Many generic USB cameras only hit high frame rates in MJPG, and crawl in raw YUYV (sometimes 1 fps at 4K). Roost captures MJPG for exactly this reason. Confirm your camera lists MJPG at the resolution you want before going further.

03 Find Your Camera's Stable Path

Roost identifies the camera by a substring of its /dev/v4l/by-path entry, not a raw /dev/videoN number, since those numbers can shift between boots. List the entries:

ls -la /dev/v4l/by-path

Find the line pointing at your camera's video node. It looks something like this:

platform-xhci-hcd.1-usb-0:1:1.0-video-index0 -> ../../video0

Note a unique chunk of that name, for example usb-0:1:1.0-video-index0. You'll put it in the config in the next step.

04 Build the Capture App

Install the build dependencies. The camera app compiles libjpeg-turbo from source, so it needs cmake and nasm in addition to the usual toolchain:

sudo apt install -y build-essential pkg-config clang libclang-dev \
    libjpeg-turbo-progs libturbojpeg0-dev cmake nasm

Install Rust via rustup. The version in apt is often too old for some of the crates here, so don't use apt unless you know this issue has been resolved:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"

Enter 1 and press enter when prompted to proceed with standard installation:

1) Proceed with standard installation (default - just press enter)
2) Customize installation
3) Cancel installation
>1
cargo --version

Copy the files onto the Roost device using scp, rsync, flash drive, flashzilla (whatever works best for you):

scp -r /Users/studio/Downloads/Roost-main roost@roost.lan:/home/roost/

Copy the example config and edit it:

cd Roost-main
cp config.example.toml config.toml
nano config.toml

Set path_identifier to the chunk you noted in step 3, and pick your resolution. For a 4K camera use:

path_identifier = "usb-0:1:1.0-video-index0"
default_width    = 3840
default_height   = 2160
jpeg_quality     = 95

Build it. The first build takes a while on the Roost device, since it's compiling the full dependency tree plus libjpeg-turbo:

cargo build --release

Run it:

./target/release/camera

You should see it open the device, report the streaming resolution, and start logging a frame count. Both WebSocket listeners come up on ports 8080 and 8081. Leave it running in this terminal.

05 Create the S3 Bucket and a Scoped Key

In the AWS console, create a private S3 bucket. Keep Block all public access turned on; the dashboard reaches photos through presigned URLs, not public access. Note your bucket name and region.

Do not use your root account keys. Create a dedicated IAM user with access scoped to just this one bucket. Make an IAM policy with this JSON, replacing the bucket name with yours:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME"
    }
  ]
}

Create an IAM user, attach that policy, then generate an access key for it. Copy the access key ID and secret somewhere safe; the secret is shown only once.

06 Set Up the Uploader

On the Roost device, put the AWS credentials in a named profile so they don't collide with anything else you have configured:

mkdir -p ~/.aws
nano ~/.aws/credentials
[roost]
aws_access_key_id = YOUR_ACCESS_KEY_ID
aws_secret_access_key = YOUR_SECRET_ACCESS_KEY
nano ~/.aws/config
[profile roost]
region = your-region-here

Open ~/Roost-main/s3_uploader.py and set BUCKET_NAME to your bucket, located in the config section under the imports. Then create a virtual environment and install boto3:

cd ~/Roost-main/
python3 -m venv uploader-venv
source uploader-venv/bin/activate
pip install boto3

Run it in its own terminal, telling boto3 which profile to use:

(uploader-venv) roost@roost:~/Roost-main $ AWS_PROFILE=roost python3 s3_uploader.py

It watches the camera's output folder, uploads each new .jpg to S3, and deletes the local copy once the upload succeeds. If the upload fails, it leaves the file in place and retries on the next pass, so you don't lose captures to a flaky connection.

Two terminals now At this point you have the camera app running in one SSH session and the uploader in another. Both need to stay up. Step 9 covers making them survive reboots; for now, two terminals is fine for testing. See? Not so bad. Now let's bring it all together!

07 Run the Dashboard

The dashboard can run anywhere with network access to both AWS and the camera. Running it on your laptop keeps the WebSocket connections off the Roost device's SSH session. It has a small Flask backend (which holds the AWS credentials and hands the page short-lived presigned URLs, so the bucket stays private) and a single-file React frontend. I'll be running it on a Windows computer.

Set up the same named AWS profile on this machine, in the same ~/.aws/credentials and ~/.aws/config format. Then, in the viewer folder:

cd viewer
python3 -m venv venv
source venv/bin/activate     # on Windows: .\venv\Scripts\Activate.ps1
pip install flask boto3
python server.py

Open http://localhost:5000 in a browser. In the top bar, click the host pill and enter your camera's address (for example roost.lan), then press Enter. The two status pills go green once the page connects to the camera's WebSocket ports.

08 Take Your First Snapshot

With all three pieces running, click Take snapshot in the dashboard. The flow you just triggered:

  1. The dashboard sends a snapshot command over WebSocket to the camera.
  2. The camera grabs the next frame, overlays a timestamp, and writes a JPEG to its output folder.
  3. The uploader sees the new file, pushes it to S3, and deletes the local copy.
  4. The dashboard's gallery polls S3 and the photo appears within a few seconds.

If it doesn't show up, check the camera terminal for a Saved line and the uploader terminal for an Uploaded line. Whichever one is missing tells you which stage to look at. Make sure the frames directory and AWS bucket indicated in s3_uploader.py exist and are accessible.

One gotcha Snapshots only get captured while the stream is running. If the camera is stopped and you hit Take snapshot or Multi-snapshot, the dashboard pops a dialog telling you the camera must be started first and offers to start it for you. Click Start camera, then take the snapshot again.

09 Add AI Recognition: DynamoDB and the Vision Lambda

This is where Roost stops being a camera and starts being searchable. Every new photo gets sent to Claude, which returns a description and a set of tags, stored in DynamoDB so the dashboard can search by what's actually in the frame.

First, create the DynamoDB table. This uses a single-table design: generic PK/SK keys plus a secondary index (GSI1) for tag lookups, which means it already supports multiple cameras and users without a redesign later.

aws dynamodb create-table \
  --table-name Roost \
  --attribute-definitions \
    AttributeName=PK,AttributeType=S \
    AttributeName=SK,AttributeType=S \
    AttributeName=GSI1PK,AttributeType=S \
    AttributeName=GSI1SK,AttributeType=S \
  --key-schema \
    AttributeName=PK,KeyType=HASH \
    AttributeName=SK,KeyType=RANGE \
  --global-secondary-indexes \
    "IndexName=GSI1,KeySchema=[{AttributeName=GSI1PK,KeyType=HASH},{AttributeName=GSI1SK,KeyType=RANGE}],Projection={ProjectionType=ALL}" \
  --billing-mode PAY_PER_REQUEST \
  --region ap-northeast-1

Next, get an Anthropic API key from console.anthropic.com (add a few dollars of credit; image analysis costs fractions of a cent each). Keep the key somewhere safe and never paste it into a terminal where it gets echoed back, or into a chat.

The vision Lambda lives in the vision/ folder of the repo. Package it together with the anthropic library. The platform flags matter: Lambda runs Linux on Python 3.11, so you must fetch Linux wheels, not whatever matches your own machine.

cd vision
python -m pip install -r requirements.txt -t package \
  --platform manylinux2014_x86_64 --python-version 3.11 --only-binary=:all:
cp handler.py package/
cd package && zip -r ../function.zip . && cd ..
Why the platform flags Without them, pip installs wheels compiled for your OS and Python version. On Windows or a newer Python, those binaries will not load in Lambda and the function fails at import with a cryptic error. The flags force the Linux 3.11 builds Lambda actually needs.

Create an execution role for the Lambda (so it can read S3, write DynamoDB, and log), then create the function. Replace ACCOUNT_ID with your 12-digit AWS account ID and supply your real API key in place of the placeholder:

aws iam create-role --role-name roost-vision-role \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'

aws iam put-role-policy --role-name roost-vision-role \
  --policy-name roost-vision-permissions \
  --policy-document file://lambda-execution-policy.json

aws lambda create-function --function-name roost-vision \
  --runtime python3.11 --handler handler.handler \
  --zip-file fileb://function.zip \
  --role arn:aws:iam::ACCOUNT_ID:role/roost-vision-role \
  --timeout 60 --memory-size 512 \
  --environment "Variables={DYNAMO_TABLE=Roost,CLAUDE_MODEL=claude-sonnet-4-6,ANTHROPIC_API_KEY=YOUR_KEY}" \
  --region ap-northeast-1

Finally, wire the bucket so new uploads invoke the Lambda:

aws lambda add-permission --function-name roost-vision \
  --statement-id s3-invoke --action lambda:InvokeFunction \
  --principal s3.amazonaws.com \
  --source-arn arn:aws:s3:::YOUR-BUCKET-NAME \
  --region ap-northeast-1

aws s3api put-bucket-notification-configuration \
  --bucket YOUR-BUCKET-NAME \
  --notification-configuration file://s3-notification.json \
  --region ap-northeast-1
Watch for BOMs on Windows If you create the JSON config files with PowerShell's Set-Content -Encoding UTF8, it adds a byte-order mark the AWS CLI rejects. Write them with [System.IO.File]::WriteAllText($path, $json, [System.Text.UTF8Encoding]::new($false)) instead, which produces UTF-8 without a BOM.

10 Test Recognition

The notification only fires on new uploads, so copy an existing object to a new key to trigger it, then check the table a few seconds later:

aws s3 cp s3://YOUR-BUCKET-NAME/some_frame.jpg s3://YOUR-BUCKET-NAME/test_trigger.jpg --region ap-northeast-1

aws dynamodb scan --table-name Roost --region ap-northeast-1

You should see an IMAGE record with a description and tags, plus one TAG record per tag. If the table is empty, read the Lambda logs to see what failed:

aws logs tail /aws/lambda/roost-vision --follow --region ap-northeast-1

11 Add Search to the Dashboard

The dashboard's search box queries the recognition tags. For it to work, the AWS identity the dashboard uses needs read access to the DynamoDB table and its index. If your dashboard credentials are a separate scoped user (recommended) rather than your admin identity, grant that user read access:

aws iam put-user-policy --user-name YOUR-DASHBOARD-USER \
  --policy-name roost-dashboard-read \
  --policy-document file://dashboard-read-policy.json

The policy grants only Query and GetItem on the Roost table and its index, nothing more, since the dashboard only ever reads. With that in place, type a tag into the dashboard's search box (try one you saw in the scan output) and the gallery filters to matching photos. Clicking a result shows Claude's description and the tags.

12 Make It Survive Reboots

Running things by hand in SSH sessions is fine for testing, but the moment you disconnect or the Roost device reboots, the pipeline stops. The repo includes a systemd unit file (camera.service) to run the capture app as a managed service that starts on boot and restarts on failure. A matching service for the uploader is a straightforward copy of the same pattern. Setting these up is the recommended last step once you're happy with how everything behaves.

Where to Go From Here

Once the basics work, a few directions are worth exploring. Fisheye lenses can be dewarped if you have the lens's distortion parameters. The device-discovery logic currently assumes one camera and could be generalized to several, which the DynamoDB schema already anticipates. And the WebSocket protocol is one-directional today; adding return messages would let the dashboard show real telemetry from the camera instead of just echoing what it sent.

Get the Code

The full project, the Rust capture app, the uploader script, and the dashboard, lives in one repo:

github.com/BottleBlueLLC/Roost

Start with the camera. Get a single frame off the device and onto disk before you worry about WebSockets, S3, or dashboards. Everything else is plumbing once that first frame exists.

About Author Blog Contact