The program is a thumbnail generator available via a web browser with full follow-along

architecture

📦 Overview: What's this thing do?

This serverless web app lets you:

  1. Upload an image (screenshot, still frame, etc.) via a simple web dashboard.
  2. First Lambda, triggered via API Gateway using a POST request, generates a presigned POST policy and uploads the image to S3 with metadata.
  3. Automatically trigger a second Lambda function using S3 event notifications.
  4. Process the image using Pillow to create a properly sized thumbnail (1280x720).
  5. Adds a text overlay using the metadata to create thumbnail text.
  6. Store the processed image in a second S3 bucket.
  7. Return a presigned URL to be downloaded.
  8. Frontend polls periodically until the corresponding file is found in the processed bucket.
  9. 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

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…💥

layers

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: 

  1. Upload an image file with text for the thumbnail
    🡢Make sure it is an acceptable file type.
    🡢The text is passed as metadata.

  2. 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).

webpage

How?

  1. 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 metadata

  2. JavaScript 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 bucket

  3. Submit 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

  1. 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.
  2. 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.