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.
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 180turns 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-stillstill 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
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-listlists everything underphotos/withlist_objects_v2./generate-presigned-urlsigns 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 |


