Skip to main content
Current1mo ago

Image Delivery S3 + CloudFront + Lambda Proxy

Tip: What this does: Store images privately in S3, use Lambda as a smart proxy layer, and deliver everything globally through CloudFront CDN — fast, cheap, and secure.


📐 Architecture

User → CloudFront CDN → Lambda (proxy) → S3 Bucket (private)

LayerRole
S3Private origin storage for all images
LambdaProxy — handles auth, resizing, routing, format conversion
CloudFrontGlobal CDN — caches and delivers from edge locations

Warning: Why not expose S3 directly to CloudFront?
Using Lambda as a proxy gives you control: authentication, on-the-fly image transformations, signed URL generation, and routing logic — none of which S3 alone can do.


🔧 Step-by-Step Setup

1 — Create a Private S3 Bucket

aws s3api create-bucket \
--bucket my-images-bucket \
--region us-east-1

aws s3api put-public-access-block \
--bucket my-images-bucket \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,\
BlockPublicPolicy=true,RestrictPublicBuckets=true"

🔒 Keep the bucket fully private. CloudFront + Lambda will be the only way to access images.


2 — Write the Lambda Proxy Function

This function receives requests from CloudFront, fetches the image from S3, and returns it.

// index.mjs (Node.js 20.x, ES Module)
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: "us-east-1" });
const BUCKET = "my-images-bucket";

export const handler = async (event) => {
// Extract image key from path e.g. /images/photo.jpg → images/photo.jpg
const key = decodeURIComponent(event.rawPath.slice(1));

try {
const command = new GetObjectCommand({ Bucket: BUCKET, Key: key });
const s3Response = await s3.send(command);

// Stream body to buffer
const chunks = [];
for await (const chunk of s3Response.Body) chunks.push(chunk);
const imageBuffer = Buffer.concat(chunks);

return {
statusCode: 200,
headers: {
"Content-Type": s3Response.ContentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000", // 1 year cache
},
body: imageBuffer.toString("base64"),
isBase64Encoded: true,
};
} catch (err) {
return { statusCode: 404, body: "Image not found" };
}
};

Deploy the function:

zip -r function.zip index.mjs node_modules/

aws lambda create-function \
--function-name image-proxy \
--runtime nodejs20.x \
--handler index.handler \
--role arn:aws:iam::YOUR_ACCOUNT:role/lambda-s3-role \
--zip-file fileb://function.zip

# Add a Function URL (so CloudFront can use it as origin)
aws lambda create-function-url-config \
--function-name image-proxy \
--auth-type NONE


3 — Grant Lambda Access to S3

Attach this IAM policy to the Lambda execution role:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::my-images-bucket/*"
}
]
}


4 — Create a CloudFront Distribution

aws cloudfront create-distribution \
--origin-domain-name YOUR_LAMBDA_FUNCTION_URL \
--default-cache-behavior '{
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"]
},
"Compress": true
}'

Note: Use the Lambda Function URL (not a raw S3 URL) as the CloudFront origin. This routes all requests through your Lambda proxy.

Key CloudFront settings to configure:

SettingRecommended Value
Viewer ProtocolRedirect HTTP → HTTPS
Cache PolicyCachingOptimized (built-in)
Compress ObjectsYes (gzip/brotli)
Price ClassUse All Edge Locations (or limit for cost)
Custom DomainOptional — add your own via ACM certificate

5 — Upload Images to S3


# Single image
aws s3 cp photo.jpg s3://my-images-bucket/images/photo.jpg

# Bulk upload a folder
aws s3 sync ./photos/ s3://my-images-bucket/images/

# Access via CloudFront

# https://d1234abcd.cloudfront.net/images/photo.jpg


🧩 Optional Enhancements

On-the-Fly Image Resizing

Add sharp to your Lambda to resize based on query params:

import sharp from "sharp";

// In your handler, after fetching from S3:
const width = parseInt(event.queryStringParameters?.w || "800");
const resized = await sharp(imageBuffer).resize(width).webp().toBuffer();

return {
statusCode: 200,
headers: { "Content-Type": "image/webp", "Cache-Control": "public, max-age=31536000" },
body: resized.toString("base64"),
isBase64Encoded: true,
};

// Usage: https://cdn.example.com/images/photo.jpg?w=400

Signed URL Auth (Private Images)

import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

// Validate token from request header first
const token = event.headers?.authorization;
if (!isValidToken(token)) {
return { statusCode: 403, body: "Forbidden" };
}

// Then serve the image normally


💰 Cost Breakdown — 10,000 Images/Month

Assumptions: Average image size 500 KB, 50,000 requests/month (5 requests per image), users spread globally, images cached well at edge.

S3 Storage

ItemCalculationMonthly Cost
Storage (5 GB)5 GB × $0.023/GB$0.12
PUT requests (upload)10,000 × $0.005/1,000$0.05
GET requests (Lambda pulls)~5,000 cache misses × $0.0004/1,000$0.00
S3 Total~$0.17

Lambda

ItemCalculationMonthly Cost
Invocations5,000 cache misses (rest served by CF)Free tier
Duration (128 MB, 200ms avg)5,000 × 0.2s × $0.0000000021/ms~$0.00
Lambda Total~$0.00

Done: Lambda has a 1M free requests/month free tier — easily covered at this scale.

CloudFront CDN

ItemCalculationMonthly Cost
Data transfer out (25 GB)25 GB × $0.0085/GB (US/EU)$0.21
HTTP requests (50,000)50,000 × $0.0000010/request$0.05
CloudFront Total~$0.26

Done: CloudFront free tier includes 1 TB data transfer + 10M requests/month for the first 12 months.


📊 Total Monthly Cost Summary

ServiceMonthly Cost
S3 Storage + Requests$0.17
Lambda~$0.00
CloudFront$0.26
Total~$0.43 / month

🎉 For 10,000 images and 50,000 deliveries per month, you're looking at under $1/month — and well within free tier limits for the first year.

Scaling Reference

ScaleImagesRequests/moEst. Cost/mo
Small10,00050,000~$0.43
Medium100,000500,000~$4.00
Large1,000,0005,000,000~$38.00
Enterprise10,000,00050,000,000~$350.00

✅ Quick Checklist

  • S3 bucket created and fully private

  • Lambda function deployed with S3 read IAM policy

  • Lambda Function URL enabled

  • CloudFront distribution pointing to Lambda URL as origin

  • Cache-Control headers set on Lambda responses

  • Custom domain + SSL certificate configured (optional)

  • Image upload pipeline in place (CLI / SDK / CI)

  • Monitoring enabled (CloudWatch for Lambda, CloudFront metrics)


Related Articles