All cloud projects
LIVE us-east-1 2026
CLOUD PROJECT · 2026

Event-driven media pipeline

A stranger can drop an image and get resized, EXIF-stripped variants back in a couple of seconds — routed through a real S3 → SQS → Lambda pipeline with a presigned-upload path, per-IP abuse guards, and a dead-letter queue that catches malformed files. No servers; about zero dollars at rest.

S3SQSLambdaAPI GatewayDynamoDBCloudFrontPillowTerraform

The problem

The other projects on this platform are read-mostly. This one is the opposite: an interactive, write-path system that a stranger on the internet can drive, which is exactly where event-driven architecture earns its keep — and exactly where the interesting failure modes live. The goal was a real pipeline (not a single do-everything Lambda) that decouples upload from processing, survives a bad input without falling over, and can’t be abused into a bill. All serverless, all inside the free tier at portfolio traffic.

The architecture

The browser never sends bytes to a server. It asks a presign Lambda (behind API Gateway, routed same-origin through the demos CloudFront distribution) for a short-lived, size-pinned presigned S3 PUT, then uploads straight to S3. That decouples the 5 MB payload from the 6 MB Lambda limit entirely.

The upload bucket fires an ObjectCreated event to SQS. A processor Lambda (Python + Pillow) consumes the queue one message at a time: it validates the file is really an image, applies EXIF orientation and strips metadata, writes thumb and medium variants, extracts the dominant color, and publishes a manifest.json. The variants are served back through a new /media/out/* behavior on the demos distribution, and the frontend polls the manifest until they appear.

The queue has a dead-letter queue behind it (maxReceiveCount 3). A malformed file — a .jpg that isn’t an image — fails in the processor, gets retried, and after three attempts lands in the DLQ, where a CloudWatch alarm pages the same SNS topic the observability canary uses.

Browserpresign λupload bucketpresignPUTSQSObjectCreatedprocessor λoutput bucketCloudFront/media/outDLQ → alarm3 fails
Upload: browser → presign Lambda → presigned PUT → S3. Process: S3 ObjectCreated → SQS → processor Lambda (Pillow) → output bucket → demos CloudFront /media/out/* → browser. Poison: 3 failed receives → DLQ → CloudWatch alarm → SNS.

Key decisions & trade-offs

Presigned direct-to-S3, not bytes-through-Lambda. Routing the file through API Gateway and Lambda would cap uploads at ~4 MB (base64 through the 6 MB synchronous limit) and pay Lambda for every byte. A presigned PUT sends the file straight to S3; Lambda only signs a URL. The cost is one real CSP decision: the demo runs on demos.andruxa.dev, so the browser’s PUT to *.s3.amazonaws.com had to be allowed in the demos distribution’s connect-src. That change is scoped to the relaxed demos CSP only — the main andruxa.dev policy stays strict. The presign endpoint itself is routed same-origin through the distribution’s /api/*, so nothing else needed loosening.

A queue between upload and processing, on purpose. S3 could invoke the processor directly. SQS in the middle buys three things: a retry boundary, back-pressure (the event source mapping caps concurrency), and — the point of the exercise — a dead-letter queue. Poison-message handling isn’t a nice-to-have here; it’s the headline. A file that passes the presign type/size check but isn’t actually a decodable image (Pillow raises UnidentifiedImageError) retries three times and lands in the DLQ with an alarm, instead of silently vanishing or wedging the pipeline. I verified this live: uploading garbage bytes with a .jpg content-type put the message in the DLQ after three failed receives, carrying the exact S3 event that failed.

Pillow bundled, not a public layer. The processor needs a compiled image library. Rather than depend on a community layer ARN outside my control, the deploy zip vendors Pillow’s manylinux / CPython-3.12 wheels via a build script — self-contained, same self-hosting ethos as the fonts on the main site.

Abuse guards before anything is stored. The presign Lambda validates type and size and enforces a per-IP daily quota using a DynamoDB token bucket (an atomic conditional UpdateItem — no read-then-write race), all before handing back an upload URL. Both buckets expire their objects after 24 hours via lifecycle rules, so a stranger can’t fill them and storage trends to zero.

Cost breakdown

ComponentMonthly cost
Lambdas (presign + processor)~$0 (free tier)
SQS + DLQ~$0 (free tier)
DynamoDB (on-demand, tiny)~$0
S3 (24h-expiry buckets)fractional cents
CloudFront (demos distribution, shared)~$0
Total≈ $0/mo at portfolio traffic

What I’d do differently at scale

For real volume the processor moves off a from-scratch Pillow handler to something purpose-built — libvips (via sharp/pyvips) is dramatically faster and lighter on memory for resizing — and the variant set becomes content-negotiated AVIF/WebP with a proper derivative cache rather than eager thumb/medium JPEGs. The presign path grows a virus/content scan step (a quarantine bucket the processor promotes from) before anything is served, and the per-IP DynamoDB bucket graduates to WAF rate-based rules at the edge so abuse is shed before it reaches an API. The DLQ gets a redrive-to-source runbook and an operator dashboard instead of a single alarm. None of that changes the shape here — upload, queue, process, dead-letter — it just hardens each stage.