Building the Real-Time Event Photo Gallery: Infrastructure, Lambda Orchestration, and the "Sailor Board" Pattern
What We Built
Over this session, we completed the photo upload, moderation, and gallery infrastructure for Queen of San Diego's post-charter experience. The "Sailor Board" — despite the marketing name suggesting an aggregate leaderboard — is actually a per-event photo gallery that lives at /g/{event_id} on each charter's guest page. We wired together:
- A Lambda-backed REST API for photo uploads with presigned S3 URLs
- Event-code-gated uploads (instant publish vs. moderation queue)
- 24-photo-per-batch upload limits with client-side UI hints
- Real-time Instagram hashtag feeds (#jada, #queenofsandiego) fetched server-side and rendered into the gallery
- CloudFront distribution invalidation on gallery updates
- A new
/sailor-board/staging page for A/B testing the feature rollout - A "Book a Sail" modal auto-open mechanism tied to deep-link query params
Architecture: Lambda + S3 + CloudFront
The photo upload flow starts in the guest page (/g/{event_id}/), served from S3 CloudFront. When a user selects photos:
- Client requests presigned URL from
GET /api/g/{event_id}/presign(Lambda, DNS-routed throughshipcaptaincrew.queenofsandiego.com) - Lambda returns time-limited S3 PUT URL and metadata (bucket, key, conditions)
- Browser uploads directly to S3 using presigned URL (bypasses Lambda, faster uploads)
- S3 triggers an event notification to the photo-processing Lambda
- Photo Lambda processes metadata, calls Instagram API, updates DynamoDB gallery index, invalidates CloudFront cache
This architecture decouples upload throughput from Lambda cold-start latency — guests don't wait for Lambda to finish processing before seeing "upload complete."
Key File Changes
Lambda handler: tools/shipcaptaincrew/lambda_function.py (multiple iterations in this session)
- Line 2030: Upload-success message: "Your photo is live. Welcome to the sailor board."
- Line 2066: Moderation email link text, pointing to
/g/{event_id} - Line 2107, 2433: Moderation reply copy mentioning the sailor board
- Presign endpoint: Generates S3 PUT policy, returns temporary credentials
- Photo fetch endpoint (
/api/g/{event_id}/photos): Returns gallery metadata + Instagram feed data from DynamoDB
Guest page: /g/2026-05-24-keely-afternoon/index.html (deployed to S3, no local source checked in)
- Line 354: Event code input (
#g-code) — copy reads "The code keeps strangers from posting to your charter page." - Line 360: Upload UI hint: "up to 24 at once"
- Line 362: File input accepts JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM
- Line 424:
readCode()function — event code enters DynamoDB check before publish - Line 452–459:
#ig-gridrenders Instagram posts fromd.instagramarray (populated by Lambda) - Line 514:
handleFiles()orchestrates upload flow, applies 24-item limit viaArray.from(files).slice(0, 24)
Homepage: index.html and sailor-board/index.html (new, staging)
- Added feature token
<!-- SAILOR_BOARD_READY -->to homepage nav - Created
sailor-board/index.htmlas soft launch landing page
Booking widget: booking-widget.js (new)
- Extracted Stripe checkout JS + modal CSS from homepage
- Supports deep-link query param
?open-booking=1to auto-open modal on page load - Prevents homepage navigation on "Book a Sail" button tap — modal opens inline instead
Infrastructure & Deployments
S3 buckets:
queenofsandiego.com— public website content (CloudFront origin)queenofsandiego-photos— guest-uploaded media (private, CORS-enabled for upload origins)- CORS policy updated to allow presigned PUT/POST from
queenofsandiego.comand staging domains
CloudFront distributions:
- Prod:
queenofsandiego.com(origin: S3 + Route53 alias) - Staging: Distribution alias for soft-launching sailor-board feature (separate from prod to prevent cache collision)
Lambda deployments:
cd tools/shipcaptaincrew
zip -r lambda_function.zip lambda_function.py dependencies/
aws lambda update-function-code \
--function-name shipcaptaincrew-prod \
--zip-file fileb://lambda_function.zip \
--region us-west-2
CloudFront invalidation (triggered on gallery update):
aws cloudfront create-invalidation \
--distribution-id E1234ABCD5678EFG \
--paths "/g/*" "/sailor-board/*"
DynamoDB schema for photos:
- Table:
QOS_Events - Partition key:
event_id(e.g.,2026-05-24-keely-afternoon) - Sort key:
timestamp(photo upload time) - Attributes:
photo_url,uploader_code_match(boolean),instagram_source(boolean),status(live/pending/rejected)
Why This Architecture?
Presigned