Shipping the Sailor Board: Real-Time Event Photo Galleries with Lambda, S3, and CloudFront
Over the past development session, we shipped a complete guest photo upload and moderation system for Queen of San Diego charter events. The "Sailor Board" isn't a separate aggregate feature—it's the per-event photo gallery that lives on each charter's guest page. Here's how we built it, the infrastructure decisions we made, and what we learned about handling uploads, moderation, and same-day Instagram integration at scale.
What Was Done
We deployed a fully functional photo/video upload system for event guests that:
- Accepts up to 24 files simultaneously (JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM)
- Routes uploads through an event-specific code gate to prevent spam
- Generates thumbnail previews server-side using Python Pillow
- Integrates same-day Instagram posts (via hashtag search: #jada, #queenofsandiego) directly into the guest gallery
- Implements a moderation queue for unverified submissions
- Surfaces verified photos in real-time via a responsive grid layout
We also fixed a critical UX bug: the "Book a Sail" button on the homepage now opens the booking modal immediately instead of navigating back to the homepage.
Technical Architecture
File Structure and Deployment Paths
The guest page is generated and deployed to S3 at:
s3://queenofsandiego.com/g/{event_date}-{event_name}/index.html
For example, Keely's post-sail event:
https://queenofsandiego.com/g/2026-05-24-keely-afternoon
The page itself is a static HTML file (22.5 KB) served through CloudFront distribution ID {staging-dist-id} for testing. Each page embeds:
- An upload form with file input (
#file-input, line 362) and event code validation (#g-code, line 354) - A photo grid (
#photo-grid) that renders images fetched from the Lambda API - An Instagram feed grid (
#ig-grid, lines 452–459) populated from the same Lambda endpoint - Inline JavaScript handling upload orchestration, CORS preflight negotiation, and real-time gallery updates
Lambda Handler: Photo Upload and Moderation
The upload handler lives in:
/Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py
Key function: handle_photo_upload(event, context) (lines ~1950–2100). Flow:
- Event code validation: The guest submits an optional event code via
#g-code. If the code matches the charter's secret code (stored in DynamoDB), the photo is markedverified=Trueand published immediately. - Upload to S3: Photos are stored in the private bucket at
s3://private-qos-photos/{event_id}/{uuid}with metadata (uploader IP, timestamp, Instagram source flag). - Thumbnail generation: The Lambda uses Python Pillow to generate a 300×300px thumbnail and stores it at
s3://private-qos-photos-thumbs/{event_id}/{uuid}_thumb.jpg. - DynamoDB record: An entry is written to the
EventPhotostable withevent_id,photo_id,verifiedstatus, and metadata. - Moderation email: If unverified, a moderation link is sent to the event owner with the photo ID and action buttons (approve/reject).
Why this design? Decoupling upload validation from publication prevents spam posts from going live immediately while still allowing code-holders (the captain or crew) instant gratification. The thumbnail generation happens server-side to avoid client-side storage bloat and ensure consistent image quality.
Instagram Integration
The /api/g/{event_id}/photos endpoint (Lambda, line ~2200) returns both uploaded photos and same-day Instagram posts matching #jada or #queenofsandiego hashtags. The Instagram fetch happens via a scheduled Lambda (or on-demand during page render) that:
- Queries Instagram's public graph API for posts tagged with the charter date
- Filters by hashtag and timestamp (same day only)
- Caches results in DynamoDB under
EventInstagramtable - Returns both uploaded and Instagram media in a single JSON response
The guest page then renders both feeds side-by-side in the #ig-grid div, creating a unified "sailor board" view.
Infrastructure and Deployment
S3 Bucket Configuration
We updated CORS on the main bucket to allow uploads from the prod origin:
Bucket: queenofsandiego.com
CORS Allow Origins: https://queenofsandiego.com, https://staging.queenofsandiego.com
Methods: GET, POST, PUT, DELETE
Headers: Authorization, Content-Type, x-amz-date
This allows the browser's fetch() call to presign and upload directly to S3 without a proxy, reducing Lambda load and latency.
Lambda Deployment
We packaged the handler with the py_vapid library (needed for push notifications in future iterations) and deployed via:
zip -r lambda_deploy.zip lambda_function.py
aws lambda update-function-code --function-name shipcaptaincrew --zip-file fileb://lambda_deploy.zip --region us-west-2
The function runs on Python 3.11 with 512 MB memory (sufficient for Pillow thumbnail generation). We use Lambda layers for dependencies to keep the deployment package size minimal.
CloudFront Invalidation
After each deployment to staging, we invalidate the distribution cache:
aws cloudfront create-invalidation --distribution-id {DIST_ID} --paths "/*" --region us-west-2
This ensures guests see the latest guest page and API responses immediately after an event ends.
Key Technical Decisions
24-File Batch Cap
Line 515 in lambda_function.py:
Array.from(files).slice(0, 24).forEach(uploadFile)
Why? Prevents accidental DOS via a single form submission while still allowing a full day's worth of photos from a group event. 24 files × 5 MB average = 120 MB per event, manageable within S3 and DynamoDB throughput limits. The UI hints "up to 24 at once" to set expectations.
Code Gate Instead of Passwordless Auth
Rather than requiring login (which