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)
| Layer | Role |
| S3 | Private origin storage for all images |
| Lambda | Proxy — handles auth, resizing, routing, format conversion |
| CloudFront | Global 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:
| Setting | Recommended Value |
| Viewer Protocol | Redirect HTTP → HTTPS |
| Cache Policy | CachingOptimized (built-in) |
| Compress Objects | Yes (gzip/brotli) |
| Price Class | Use All Edge Locations (or limit for cost) |
| Custom Domain | Optional — 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
| Item | Calculation | Monthly 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
| Item | Calculation | Monthly Cost |
| Invocations | 5,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
| Item | Calculation | Monthly 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
| Service | Monthly 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
| Scale | Images | Requests/mo | Est. Cost/mo |
| Small | 10,000 | 50,000 | ~$0.43 |
| Medium | 100,000 | 500,000 | ~$4.00 |
| Large | 1,000,000 | 5,000,000 | ~$38.00 |
| Enterprise | 10,000,000 | 50,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)