By the end of this lab, you'll have a fully serverless system where:
Requirements:
- One S3 bucket with a
uploads/prefix and athumbs/prefix - A Lambda function that runs whenever a new image lands in
uploads/ - The function resizes the image to 200px wide (keep aspect ratio) and writes it to
thumbs/ - The whole thing must work end-to-end with zero EC2, zero servers, zero SSH
Create a brand new S3 bucket. Suggested name: cloudwithshad-resizer-<your-initials>-<random-number> (bucket names must be globally unique).
uploads/ and thumbs/Hint: how to create a "folder" in S3
S3 doesn't really have folders β it has object key prefixes. But the console lets you click "Create folder" and it adds a zero-byte object ending in /. That's enough to act like a folder for our trigger.
Your Lambda function needs permission to do three things: read from S3, write to S3, write logs to CloudWatch.
- Go to IAM β Roles β Create role
- Trusted entity type: AWS service
- Use case: Lambda
- Attach these managed policies:
AWSLambdaBasicExecutionRoleβ for CloudWatch logsAmazonS3FullAccessβ for read/write to S3 (we'll tighten this later)
- Role name:
cloudwithshad-resizer-role
In production, you'd never use S3FullAccess β you'd write a custom policy granting access only to that ONE bucket. This is the principle of least privilege. For learning, broad permissions are fine.
- Go to Lambda β Create function
- Author from scratch
- Function name:
resize-image - Runtime: Python 3.12
- Architecture: x86_64
- Execution role: Use existing β
cloudwithshad-resizer-role - Click Create function
Once created, scroll to the Code tab. Paste this code:
Click Deploy.
Real-world image processing is messy. A naΓ―ve 25-line resizer works for "normal" JPEGs but breaks on roughly half the images people actually upload. Here's what each defensive block fixes:
ImageOps.exif_transposeβ phone photos store rotation as EXIF metadata, not pixel data. Without this, portrait shots come out sideways.convert_mode()for RGBA / LA / P β PNG-with-transparency, palette GIFs, and screenshots have an alpha channel. JPEG can't store alpha β saving raisesOSError: cannot write mode RGBA as JPEG. We flatten transparency onto white.FORMAT_MAPβ PNG in β PNG out (preserves transparency for logos/screenshots). JPEG in β JPEG out. Weird stuff (BMP/TIFF/CMYK) β JPEG. The output extension and S3ContentTypematch the actual format so browsers display it correctly inline.UnidentifiedImageErrorcatch β iPhone HEIC photos and corrupted files crash Pillow. We log and skip cleanly so S3 doesn't retry the same broken file 3 times.MAX_IMAGE_PIXELSβ Pillow refuses images bigger than ~89 megapixels by default (anti-bomb security). Modern phone cameras push 50MP, drone shots push 100MP+. We raise the limit to 200M.unquote_plus(key)β S3 event keys are URL-encoded. A filename with a space arrives asmy+photo.jpg; without decoding,get_objectfails with "key not found."thumbs/early return + the trigger prefix β defence in depth against the infinite-loop trap.
You'll see this pattern in every production Lambda you write: the happy path is short, the defensive code around it is what keeps it alive at 3 AM.
The code logs and skips HEIC files cleanly β it won't crash, but it won't resize them either. If you want HEIC support, you need an extra Lambda Layer (pillow-heif). For this lab we keep it simple: tell users to upload JPEG/PNG/WebP/GIF/BMP/TIFF. iPhone users can change their camera setting to "Most Compatible" instead of "High Efficiency" to save as JPEG by default.
The code uses the Pillow library, but Lambda's Python runtime doesn't include it. You'll get No module named 'PIL' the first time you test. The fix: attach a Lambda Layer that provides Pillow. AWS doesn't publish one, but the community-maintained Klayers project does β and it's free.
Add the Pillow layer β exact steps:
- Scroll down on the function page β find the Layers section β click "Add a layer"
- Three radio options appear: AWS layers, Custom layers, Specify an ARN β pick Specify an ARN
- Paste this ARN (for us-east-1, Python 3.12):
arn:aws:lambda:us-east-1:770693421928:layer:Klayers-p312-Pillow:11
β΅ Verified working as of June 2026 (Pillow 12.2.0). If you get a "version not found" error, the version at the end (
:11) has bumped β see the callout below to look up the current one. - Click Verify β the box on the right should say "Compatible runtimes: python3.12" β
- Click Add
The ARN above is specific to us-east-1 and Python 3.12. If your function is in another region or runtime, check the canonical list β Klayers publishes the current ARN per region/runtime here:
https://api.klayers.cloud/api/v2/p3.12/layers/latest/us-east-1/Pillow
Replace us-east-1 with your region, or p3.12 with p3.11 / p3.10 as needed. If you get a "version not found" error, the version number at the end of the ARN (:11) has bumped β check the URL above for the latest and update accordingly.
Optional: build your own Pillow layer instead
Klayers is the easy path. If you want the harder, more educational route: download Pillow on a Linux machine, zip it into a python/ folder, upload as your own layer. AWS docs walk through this at docs.aws.amazon.com/lambda/latest/dg/python-layers.html. More work, more learning β totally optional for this lab.
Now configure timeout and memory (don't skip this)
Image resizing takes a few seconds and Lambda's defaults are too tight. You'll see Task timed out after 3.00 seconds in CloudWatch logs if you skip this.
- Still on the function page β Configuration tab β General configuration β Edit
- Memory: set to 512 MB (Pillow needs the headroom)
- Timeout: set to 30 seconds (gives image processing breathing room)
- Save
Now we connect S3 to Lambda. In the Lambda function page:
- Click "+ Add trigger" (in the function overview diagram at the top)
- Select S3 from the dropdown
- Bucket: your resizer bucket
- Event types: All object create events
- Prefix:
uploads/β critical β limits the trigger to the uploads folder only - Suffix: leave empty
- Tick "I acknowledge that using the same S3 bucket for both input and output is not recommended..." (we handle this in code with the prefix check, so it's safe)
- Click Add
You may see an "Add destination" button somewhere on the function page (or after a trigger fails). Ignore it for this lab. "Destinations" are for sending the Lambda's invocation result (success or failure) to another service like SQS, SNS, or EventBridge. It is NOT the same as "configure where the output image goes" β that's already handled by our code writing to the thumbs/ prefix.
If AI tools have told you to create a second S3 bucket as a destination, that's a different (also valid) architecture pattern β but it's not what this lab uses. One bucket with two prefixes works just fine.
Without the uploads/ prefix, the trigger would fire when Lambda writes the thumb back into S3 β which would trigger another resize β infinite loop. The prefix + the early-return check in our code (if key.startswith('thumbs/')) both prevent this. Defence in depth.
The moment of truth.
- Open your S3 bucket in the console
- Navigate into
uploads/ - Click Upload and drop in any JPG or PNG (a selfie, a screenshot, anything under 5 MB)
- Wait ~5 seconds
- Go to the
thumbs/folder and refresh - You should see your image β resized to 200px wide π
If you check the Lambda function's Monitor tab, you'll see:
- Invocation count: 1
- Average duration: 1-3 seconds
- Errors: 0 (hopefully)
Take a screenshot showing both the original (in uploads/) and the resized version (in thumbs/) side by side. Post it in the cohort WhatsApp group with the caption "Lab 4 done".
Hint: what to do when it doesn't work
Go to CloudWatch β Log groups β /aws/lambda/resize-image. Open the most recent log stream. The error will be in plain English. Most common issues:
No module named 'PIL'β Pillow layer not attachedTask timed outβ bump timeout from 3s to 30sAccessDeniedβ IAM role missing S3 permissions- Nothing in logs at all β trigger isn't firing. Check prefix & bucket name.
Lambda + S3 won't break the bank, but tidy habits matter. After your screenshot:
- Empty the S3 bucket first (Bucket β Empty), then Delete the bucket
- Delete the Lambda function (Lambda β resize-image β Delete)
- Delete the IAM role (IAM β Roles β cloudwithshad-resizer-role β Delete)
- Optional: delete the CloudWatch log group (/aws/lambda/resize-image) β logs are charged after 5 GB
Serverless changes how you think. With EC2 you ask "what server do I need?" With Lambda you ask "what should trigger when?" Both have their place β but the future is event-driven. You just built one. Welcome to serverless.
Lab 4 complete!
You just built a real-world serverless workflow. The same pattern powers Instagram thumbnails, e-commerce product galleries, and document scanners.
Next week: Auto-scaling, load balancing, and CI/CD pipelines