A Raspberry Pi 3 Model B with the official Camera Module 3 sits on my balcony, pointing north. Since midnight on July 8, 2025, it has taken a photo every three hours and uploaded it to S3: about 3,800 photos and 1 GB so far. A gallery shows them, now at iot.rrjamal.ca.

This post is about the first version: a cron job, a Python script and a small serverless gallery. It worked, but with problems, among them an access key on the Pi, a photo list that stopped at 1,000, and a backend that lived inside my portfolio site.

The camera's view at 6 a.m. on its first day: an overcast dawn over trees, townhouses and a tower block
6 a.m., July 8, 2025
The same view at 9 a.m., with morning traffic on the road
9 a.m.
The same view at 9 p.m., under a pink and orange sunset
9 p.m.

How it fits together

flowchart LR
    subgraph balcony["Balcony"]
        Pi["Raspberry Pi 3<br/>cron, every 3 hours"]
    end
    subgraph aws["AWS"]
        S3[("S3 bucket<br/>photos/")]
        API["API Gateway<br/>+ Lambda"]
    end
    Browser["Gallery page<br/>on rrjamal.ca"]
    Pi -- "upload,<br/>access key" --> S3
    Browser -- "1 · list photos" --> API
    API -- "list, sign URLs" --> S3
    Browser -- "2 · fetch each photo<br/>by presigned URL" --> S3

The bucket is private. The Pi writes to it with its own credentials, and the gallery reads from it through a Lambda function that lists the photos and hands out short-lived links to each one.

The Pi

Cron runs a short Python script. It takes the photo with libcamera-still and uploads it with boto3:

cmd = [
    "libcamera-still",
    "-o", filepath,
    "-n",            # No preview window
    "--width", "1920",
    "--height", "1080",
    "--rotation", "180"
]
result = subprocess.run(cmd, capture_output=True, text=True)
...
s3 = boto3.client("s3")
s3.upload_file(filepath, S3_BUCKET, f"photos/{filename}")
0 */3 * * * /usr/bin/python3 /home/pi/capture_photo.py >> /home/pi/photo_log.txt 2>&1
  • The file name is the time, photo_20250708_060002.jpg, so sorting by name sorts by date. The gallery relies on that.
  • --rotation 180 turns the image the right way up for how the camera is mounted.
  • The photo stays on the Pi after the upload, so the SD card is a second copy of everything.
  • On newer Raspberry Pi OS, the command is rpicam-still. libcamera-still still works as an alias, for now.

Credentials were the quick version. Terraform created an IAM user, pi-photo-uploader, and an access key for it, and output the secret so I could copy it into ~/.aws/credentials on the Pi, where boto3 finds it with no code.

The gallery was a page on rrjamal.ca, backed by one Python Lambda function behind API Gateway at api.rrjamal.ca, deployed with SAM from GitHub Actions. It had two routes:

  • /image-summary-list lists everything under photos/ with list_objects_v2.
  • /generate-presigned-url signs a link to one photo, valid for an hour.

The page loads the list, sorts it newest first, and asks for a presigned URL for the photo on screen and the two either side, so the arrows feel instant.

A presigned URL lets a browser download one private object without AWS credentials of its own. The Lambda's role signs it, and S3 checks the signature and expiry. The bucket never has to be public.

What was wrong with it

The access key. It never expired, worked from any machine it was copied to, and its policy allowed reading and listing as well as uploading. Because Terraform made it, the secret also sat in the Terraform state file. Replacing it with a certificate is its own article.

The list stopped at 1,000 photos. list_objects_v2 returns at most 1,000 keys per call, oldest first for these names, and the Lambda made one call. The 1,000th photo arrived at 9 a.m. on November 7, 2025, and from noon the gallery quietly stopped showing new ones. I fixed it that evening by following the continuation token, but capped at five pages: the 5,001st photo, due around March 2027, would have broken it again.

It was heavier than it needed to be. Every photo on screen cost a call to the Lambda, and the function had provisioned concurrency to avoid cold starts, which means paying to keep it warm all day for a gallery that's idle most of the time.

It lived inside my portfolio site. The Lambda, the Pi script and the IAM user were all in the same repo and AWS stack as rrjamal.ca. In October 2026 the project moved to its own repo and iot.rrjamal.ca, where CloudFront serves the photos directly and the Pi gets one-hour credentials from IAM Roles Anywhere instead of a key.

First version (July 2025) Now (October 2026)
Pi credentials An access key that never expires A certificate, traded for one-hour credentials
What the Pi can do Upload, read and list Upload to photos/ only
How the browser gets a photo A presigned URL per photo, from the Lambda Straight from CloudFront
Where it lives Inside the portfolio site Its own repo and subdomain