The program is a thumbnail generator available via a web browser with full follow-along
📦 Overview: What's this thing do?
This serverless web app lets you:
- Upload an image (screenshot, still frame, etc.) via a simple web dashboard.
- First Lambda, triggered via API Gateway using a POST request, generates a presigned POST policy and uploads the image to S3 with metadata.
- Automatically trigger a second Lambda function using S3 event notifications.
- Process the image using Pillow to create a properly sized thumbnail (1280x720).
- Adds a text overlay using the metadata to create thumbnail text.
- Store the processed image in a second S3 bucket.
- Return a presigned URL to be downloaded.
- Frontend polls periodically until the corresponding file is found in the processed bucket.
- Second Lambda then generates and returns the presigned URL from the processed bucket.
🔧 Services: How's this thing do what it does?
Amazon S3 - store raw uploads and processed images.
AWS Lambda - perform image processing using Pillow.
Amazon API Gateway - support uploading and presigned URL generation from the web.
IAM - permission control.
Simple HTML Dashboard - static website for uploads and image preview.
Let's dive in and build this thing!
⚙️Build: How do you get the thing to do what it does?
1. Set Up Two S3 Buckets
Just create two S3 buckets. Name them:
something-uploads-related
→ stores original uploads
something-processed-related
→ stores finalized thumbnails
Then, on the something-uploads-related
bucket, enable S3 event notifications to trigger the Lambda function that we are going to write next, on object creation.
2. Write Image Processing Lambda Function with Python & Pillow
The logic for the function should be as follows:
- Get the image from the
something-uploads-related
bucket - Collect metadata about the image from the user input on the webpage
- Resize it to YouTube's recommended 1280x720 resolution (or maintain aspect ratio + pad) using Pillow
- Generate thumbnail text using the metadata and add text as an overlay
- Save it to
something-processed-related
bucket
So the flow becomes something like this:
🡢The User Uploads an Image to S3: The user uploads an image to the S3 bucket via the web browser. The webpage will provide the user with the space to include thumbnail text for the overlay.
🡢The Lambda gets Triggered by Upload: Lambda processes the image file and stores it in another S3 bucket.
This is the basic code for the Lambda function:
from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
import boto3
import os
import io
s3 = boto3.client('s3')
# Assume you added "Impact.ttf" font to your deployment package or layer
# Font path and size for text overlay is detailed in the Lambda layer
FONT_PATH = "/opt/Impact.ttf" # If added via Lambda Layer
FONT_SIZE = 64
def lambda_handler(event, context):
bucket = event['Records'][0]['s3']['bucket']['name']
key = event['Records'][0]['s3']['object']['key']
try:
# Get metadata (for custom text)
head = s3.head_object(Bucket=bucket, Key=key)
text = head.get("Metadata", {}).get("text", "Your Title Here")
# Download image
original_image = s3.get_object(Bucket=bucket, Key=key)['Body'].read()
try:
image = Image.open(io.BytesIO(original_image)).convert("RGB")
except UnidentifiedImageError:
return {
'statusCode': 400,
'body': 'The uploaded file is not a valid image.'
}
# Resize to 1280x720
image.thumbnail((1280, 720))
# Creates a black canvas that is always 1280xx720
canvas = Image.new('RGB', (1280, 720), (0, 0, 0))
# Paste the resized image on the center of the black canvas
canvas.paste(image, ((1280 - image.width) // 2, (720 - image.height) // 2))
# ImageDraw.Draw lets you draw shapes, lines, or text onto a Pillow image
draw = ImageDraw.Draw(canvas)
# Apply translucent black overlay for contrast
# (later will improve this logic to make it dynamic)
overlay = Image.new('RGBA', canvas.size, (0, 0, 0, 100))
canvas = Image.alpha_composite(canvas.convert('RGBA'), overlay)
# Load font
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
# Text placement
text_position = (50, 600)
draw = ImageDraw.Draw(canvas)
draw.text(text_position, text, font=font, fill='white')
# Save final output
output = io.BytesIO()
canvas.convert("RGB").save(output, format='JPEG')
output.seek(0)
# Upload to processed bucket
processed_key = f"processed-{key}"
processed_bucket = 'something-processed-related'
s3.upload_fileobj(output, processed_bucket, processed_key, ExtraArgs={'ContentType': 'image/jpeg'})
# Generate presigned URL
url = s3.generate_presigned_url('get_object', Params={
'Bucket': processed_bucket,
'Key': processed_key
}, ExpiresIn=3600)
return {
'statusCode': 200,
'body': url
}
except Exception as e:
return {
'statusCode': 500,
'body': f'Error: {str(e)}'
}
AWS Lambda does not have Pillow installed by default, so you should create a Lambda layer with Pillow pre-installed and attach it to your function.
Use a pre-existing layer for Pillow, like:
https://github.com/keithrozario/Klayers/tree/master/deployments/python3.12
Once the layer is created, just attach it to the function.
Then, add another Lambda layer for font styles for the thumbnail text. You can find many different fonts here:
https://fontsgeek.com/impactall-font
Once again, when the layer is created, just attach it to the function.
and…💥
3. Write the Lambda to Generate a Presigned POST URL
✅ Use Case Recap:
- Browser sends the file and text to this Lambda
- Lambda generates a presigned
POST
policy - Browser uploads directly to S3 using the
POST
form
This Lambda code could look something like this:
import json
import boto3
import os
from urllib.parse import parse_qs
s3 = boto3.client('s3')
UPLOAD_BUCKET = 'something-upload-related'
PROCESSED_BUCKET = 'something-process-related'
def lambda_handler(event, context):
method = event.get('httpMethod')
path = event.get('path')
if method == 'POST' and path == '/generate-presigned-url':
return generate_presigned_post(event)
elif method == 'GET' and path == '/get-final-url':
return get_processed_image_url(event)
else:
return {
'statusCode': 404,
'body': json.dumps({'error': 'Route not found'})
}
def generate_presigned_post(event):
try:
body = json.loads(event['body'])
filename = body.get('filename')
text = body.get('text', '') # Optional
if not filename:
return {
'statusCode': 400,
'body': json.dumps({'error': 'Filename is required'})
}
# Create a presigned POST to upload the file with metadata
presigned_post = s3.generate_presigned_post(
Bucket=UPLOAD_BUCKET,
Key=filename,
Fields={"x-amz-meta-text": text},
Conditions=[
{"x-amz-meta-text": text},
["starts-with", "$Content-Type", ""]
],
ExpiresIn=300 # 5 minutes
)
return {
'statusCode': 200,
'body': json.dumps(presigned_post)
}
except Exception as e:
return {
'statusCode': 500,
'body': json.dumps({'error': str(e)})
}
def get_processed_image_url(event):
try:
params = event.get('queryStringParameters') or {}
filename = params.get('filename')
if not filename:
return {
'statusCode': 400,
'body': json.dumps({'error': 'Missing filename'})
}
processed_key = f'processed-{filename}'
# Check if the file exists
try:
s3.head_object(Bucket=PROCESSED_BUCKET, Key=processed_key)
except s3.exceptions.ClientError as e:
if e.response['Error']['Code'] == '404':
return {
'statusCode': 404,
'body': json.dumps({'message': 'Thumbnail not ready yet'})
}
else:
raise
# File exists, generate download URL
presigned_url = s3.generate_presigned_url('get_object', Params={
'Bucket': PROCESSED_BUCKET,
'Key': processed_key
}, ExpiresIn=3600)
return {
'statusCode': 200,
'body': json.dumps({'url': presigned_url})
}
except Exception as e:
return {
'statusCode': 500,
'body': json.dumps({'error': str(e)})
}
For the presigned POST URL Lambda to work, it needs an API Gateway hookup. Create a new HTTP API and add routes POST /generate-presigned-url
and GET /get-final-url
integrate it with the Lambda.
Then test it using the API endpoint and the command line using thecURL command.
4. Create the Web Dashboard
The end user should have a clean, easy dashboard to upload images and receive thumbnails. So we will create a static webpage using S3 that allows the user to:
Upload an image file with text for the thumbnail
🡢Make sure it is an acceptable file type.
🡢The text is passed as metadata.Receive a thumbnail of the image for download
🡢A link is provided to view and download the image.
🡢The image is stored in an S3 bucket (with a lifecycle policy).
How?
Submit button triggers the Lambda using an API endpoint to generate a presigned URL to upload to S3
🡢API Gateway passes along the image file and thumbnail text stored as metadataJavaScript on the webpage uploads an image file to the S3 bucket with the presigned URL
🡢Uploads to the S3 bucket trigger the Lambda function to process the image and store it in the processed images bucketSubmit button also triggers another Lambda using an API endpoint to poll the S3 bucket for processed images for the processed image, creates a presigned URL and returns it
🡢Same submit button (with a short delay) triggers another Lambda to search the processed images S3 bucket for an object with the proper filename
🡢Poll repeats periodically and when found, generates and returns a presigned URL back to the user for viewing or downloading
To do so, create another S3 bucket and enable static website hosting, set a bucket policy, and unblock all public access (we can go back and make this more secure later). Then upload an index.html file to the bucket. The JavaScript in my file looked something like this:
document.getElementById('upload-form').addEventListener('submit', async (e) => {
e.preventDefault();
const text = document.getElementById('text').value;
const filetype = document.getElementById('filetype').value;
const fileInput = document.getElementById('image');
const file = fileInput.files[0];
if (!file) {
alert("Please choose a file.");
return;
}
// Step 1: Request a presigned URL
const presignedRes = await fetch("[API-ENDPOINT]", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
text: text,
filetype: filetype
})
});
const { url, filename } = await presignedRes.json();
// Step 2: Upload file to S3 using presigned URL
const uploadRes = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": file.type
},
body: file
});
if (uploadRes.ok) {
document.getElementById("result").innerHTML = `
<strong>✅ Uploaded successfully!<br>
Processed thumbnail will appear shortly:<br>
<code>${filename}
5. Create the Third Lambda for Polling
At this point in the project, the webpage is up and communicating with the presigned URL generator Lambda. When you hit the "generate thumbnail" button, the API endpoint passes the image file and thumbnail text as metadata to the URL generation Lambda, which creates the presigned URL and returns it to the webpage. Then the webpage uploads the image file to the uploads bucket using the URL. When the image file gets uploaded to the bucket, the event notification triggers the thumbnail generator Lambda, which creates the thumbnail and stores it in the processed images bucket.
Now, what we have to set up here is how we are going to get the processed image back to the user. We are going to do this with another Lambda function for polling the processed images bucket for the new file and returning it back to the user. So, we will put another API endpoint on the "generate thumbnail" button, which waits a couple of seconds, then triggers the polling Lambda. The code for this Lambda is below.
import json
import boto3
import os
s3 = boto3.client('s3')
PROCESSED_BUCKET = 'something-processed-related'
def lambda_handler(event, context):
try:
# If REST API: filename is in queryStringParameters
# If HTTP API: filename may be in event['queryStringParameters']
params = event.get("queryStringParameters") or event.get("querystring") or {}
filename = params.get("filename")
if not filename:
return {
'statusCode': 400,
'body': json.dumps("Missing filename parameter.")
}
processed_key = f"processed-{filename}"
# Try to get object metadata to confirm it exists
s3.head_object(Bucket=PROCESSED_BUCKET, Key=processed_key)
# If it exists, generate presigned URL
url = s3.generate_presigned_url('get_object', Params={
'Bucket': PROCESSED_BUCKET,
'Key': processed_key
}, ExpiresIn=3600)
return {
'statusCode': 200,
'headers': {'Access-Control-Allow-Origin': '*'}, # CORS
'body': json.dumps({'downloadUrl': url})
}
except s3.exceptions.ClientError as e:
if e.response['Error']['Code'] == '404':
return {
'statusCode': 404,
'headers': {'Access-Control-Allow-Origin': '*'}, # CORS
'body': json.dumps("Thumbnail not ready yet.")
}
return {
'statusCode': 500,
'headers': {'Access-Control-Allow-Origin': '*'}, # CORS
'body': json.dumps("Error checking file.")
}
6. Security & IAM
- Lambda needs
s3:GetObject
,s3:PutObject
for both buckets, but not more. - Restrict the bucket policy and bucket access.
- If using presigned POST or API Gateway, you'll control access via CORS but generating a CORS config template.
Issues: Why isn't this thing doing what it does ❓
1.🛡️CORS
Problem:
My frontend was politely trying to talk to my Lambda endpoints… but CORS was like "who are you again?"
Symptom:
Chrome Dev Tools screamed:
Access to fetch at '...' from origin '...' has been blocked by CORS policy.
Solution:
I added the right headers in every Lambda response - especially for 4XX and 5XX errors, which are often forgotten:
'headers': {
'Access-Control-Allow-Origin': '*'
}
2.🔁Thumbnail "Still Processing" Forever
Problem:
Even after uploading, the frontend would just loop "Thumbnail still processing. Try again shortly."
Cause:
The Lambda that checks for the processed thumbnail was using head_object() to look for the file-but my IAM role didn't have s3:GetObject
permission, which is sneakily required to do a HeadObject
!
Fix:
I added this permission to the role:
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-processed-bucket-name/*"
}
3. ✔️❌Good URL, Bad Upload
Problem:
I'd get a valid presigned URL, but uploading the file would silently fail or return a confusing 403.
Diagnosis:
The Content-Type
of the file must match the one used when generating the URL. A mismatch breaks the signature.
*Solution:
* I explicitly set the Content-Type
during both the presigned URL generation in Lambda and the fetch()
upload call on the frontend.
V2 Improvements: How to do what it does…better
- Improve Logic - Thumbnails generally have text on top of the images and can be added in many different ways, depending on the image itself. Make the text overlay dynamic depending on the colors and contrast of the image itself.
- Infrastructure as Code (IaC) - Make this whole project into one file in Terraform. Automation and easy source control.
🧪 Want to Try It Yourself?
You can view the full repo and clone it for your own AWS account here:
https://github.com/Judewakim/thumbnailgenerator
For more projects like this, check out my other Medium posts and check out my Github and LinkedIn.