Every serverless project eventually needs to accept files: customer document uploads, profile images, CSV imports, video clips. The instinct is to POST the file to an API endpoint, and that instinct is wrong. API Gateway caps payloads at 10 MB, Lambda caps its request payload at 6 MB synchronously, and you pay Lambda duration for the time spent shovelling bytes that S3 could have received for free.
The correct shape is: the browser uploads straight to S3 with a short-lived presigned credential, S3 emits an event, and a separate Lambda does the processing. This tutorial builds that pipeline end to end with Serverless Framework V4 — presigned POST generation, a hardened bucket, an image-processing consumer on arm64 with Sharp, a DLQ, idempotency, and the lifecycle rules that keep the bucket from becoming a landfill.
Stack: Node.js 22, TypeScript, @aws-sdk/client-s3 plus @aws-sdk/s3-presigned-post, Serverless Framework V4.
The architecture
browser --POST /uploads/sign--> Lambda (sign) --> presigned POST fields
browser --POST form-data-------> S3 uploads bucket (uploads/ prefix)
S3 event ---------------------> SQS queue --> Lambda (process) --> S3 derived bucket
|
+--> DLQ after 5 failed receives
Two details that matter more than they look:
- SQS between S3 and the processor. Wiring S3 notifications directly to Lambda works until you get a burst of 5,000 uploads and your processor throttles, or until a poison-pill file retries forever. A queue gives you buffering, batching, a redrive policy, and a real DLQ.
- Two buckets, or at minimum two prefixes with different notification filters. If the processor writes its output back into the bucket it is watching, it triggers itself. That recursion has burned real money for real teams. A separate
derivedbucket removes the possibility entirely.
1. serverless.yml
service: uploads-pipeline
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs22.x
region: us-east-1
architecture: arm64
memorySize: 1024
timeout: 29
environment:
UPLOAD_BUCKET: !Ref UploadBucket
DERIVED_BUCKET: !Ref DerivedBucket
MAX_UPLOAD_BYTES: '26214400' # 25 MB
iam:
role:
statements:
- Effect: Allow
Action: s3:PutObject
Resource: !Sub '${UploadBucket.Arn}/uploads/*'
- Effect: Allow
Action: s3:GetObject
Resource: !Sub '${UploadBucket.Arn}/uploads/*'
- Effect: Allow
Action: s3:PutObject
Resource: !Sub '${DerivedBucket.Arn}/*'
build:
esbuild:
bundle: true
minify: true
target: node22
exclude:
- '@aws-sdk/*'
external:
- sharp
functions:
sign:
handler: src/sign.handler
memorySize: 512
timeout: 10
events:
- httpApi:
path: /uploads/sign
method: post
process:
handler: src/process.handler
memorySize: 2048 # Sharp is CPU-bound; more memory means more vCPU
timeout: 120
layers:
- arn:aws:lambda:${aws:region}:XXXXXXXXXXXX:layer:sharp-arm64:1
events:
- sqs:
arn: !GetAtt UploadQueue.Arn
batchSize: 5
maximumBatchingWindow: 10
functionResponseType: ReportBatchItemFailures
ReportBatchItemFailures is the single most important line in that block. Without it, one bad message in a batch of five causes all five to be retried.
2. The buckets and the queue
Add these under resources:. They are ordinary CloudFormation, and V4 resolves !Ref and !GetAtt against them normally.
resources:
Resources:
UploadBucket:
Type: AWS::S3::Bucket
Properties:
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
CorsConfiguration:
CorsRules:
- AllowedMethods: [POST]
AllowedOrigins: ['https://app.example.com']
AllowedHeaders: ['*']
MaxAge: 3000
LifecycleConfiguration:
Rules:
- Id: expire-raw-uploads
Status: Enabled
Prefix: uploads/
ExpirationInDays: 7
- Id: abort-incomplete-multipart
Status: Enabled
AbortIncompleteMultipartUpload:
DaysAfterInitiation: 1
NotificationConfiguration:
QueueConfigurations:
- Event: 's3:ObjectCreated:*'
Filter:
S3Key:
Rules:
- Name: prefix
Value: uploads/
Queue: !GetAtt UploadQueue.Arn
DerivedBucket:
Type: AWS::S3::Bucket
Properties:
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
UploadQueue:
Type: AWS::SQS::Queue
Properties:
VisibilityTimeout: 720 # at least 6x the process function timeout
RedrivePolicy:
deadLetterTargetArn: !GetAtt UploadDlq.Arn
maxReceiveCount: 5
UploadDlq:
Type: AWS::SQS::Queue
Properties:
MessageRetentionPeriod: 1209600 # 14 days
UploadQueuePolicy:
Type: AWS::SQS::QueuePolicy
Properties:
Queues: [!Ref UploadQueue]
PolicyDocument:
Statement:
- Effect: Allow
Principal: { Service: s3.amazonaws.com }
Action: sqs:SendMessage
Resource: !GetAtt UploadQueue.Arn
Condition:
StringEquals:
'aws:SourceAccount': !Ref AWS::AccountId
Three things people get wrong here:
- The queue policy is mandatory. Without it the stack deploys and S3 silently cannot deliver notifications. The
aws:SourceAccountcondition prevents the confused-deputy problem. - Visibility timeout must exceed the function timeout, by a comfortable margin (AWS recommends 6x). Otherwise a slow message becomes visible again and is processed twice while the first attempt is still running.
- The raw-upload lifecycle rule. Uploaded originals are usually worthless once derived output exists. Seven days is generous; the alternative is paying storage on every file anyone ever uploaded, forever.
3. Generating the presigned POST
A presigned POST is preferable to a presigned PUT for browser uploads, because the policy can enforce a content-length range and a content-type condition that the client cannot override. A presigned PUT URL cannot cap file size.
src/sign.ts:
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { randomUUID } from 'node:crypto';
import { S3Client } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
const s3 = new S3Client({});
const MAX = Number(process.env.MAX_UPLOAD_BYTES);
const ALLOWED: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'application/pdf': 'pdf',
};
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
// In a real app, resolve the tenant/user from the JWT claims on the request.
const userId = event.requestContext.authorizer?.jwt?.claims?.sub as string | undefined;
if (!userId) return { statusCode: 401, body: JSON.stringify({ error: 'unauthenticated' }) };
const { contentType } = JSON.parse(event.body ?? '{}');
const ext = ALLOWED[contentType];
if (!ext) {
return { statusCode: 400, body: JSON.stringify({ error: 'unsupported contentType' }) };
}
const key = `uploads/${userId}/${randomUUID()}.${ext}`;
const presigned = await createPresignedPost(s3, {
Bucket: process.env.UPLOAD_BUCKET!,
Key: key,
Expires: 300, // 5 minutes
Conditions: [
['content-length-range', 1, MAX],
['eq', '$Content-Type', contentType],
],
Fields: { 'Content-Type': contentType },
});
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key, ...presigned }),
};
};
Note what the server controls: the key (so a user cannot overwrite another tenant's object), the expiry, the maximum size, and the allowed content types. The client controls nothing but the bytes.
The browser side is a plain multipart form — no AWS SDK in the front end:
const { url, fields, key } = await fetch('/uploads/sign', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ contentType: file.type }),
}).then((r) => r.json());
const form = new FormData();
Object.entries(fields).forEach(([k, v]) => form.append(k, v));
form.append('Content-Type', file.type);
form.append('file', file); // 'file' must be appended last
const res = await fetch(url, { method: 'POST', body: form });
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
file last is not a style preference. S3 ignores any form field that appears after the file content.
4. Processing the upload
src/process.ts:
import type { SQSHandler, SQSRecord } from 'aws-lambda';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import sharp from 'sharp';
const s3 = new S3Client({});
const SIZES = [{ name: 'thumb', width: 240 }, { name: 'medium', width: 1024 }];
type S3Notification = {
Records?: { s3: { bucket: { name: string }; object: { key: string; size: number } } }[];
};
const processRecord = async (record: SQSRecord) => {
const body = JSON.parse(record.body) as S3Notification;
// S3 test events have no Records array; skip them rather than failing.
for (const r of body.Records ?? []) {
const bucket = r.s3.bucket.name;
const key = decodeURIComponent(r.s3.object.key.replace(/\+/g, ' '));
if (!/\.(jpg|png|webp)$/i.test(key)) {
console.log(JSON.stringify({ event: 'skip_non_image', key }));
continue;
}
const obj = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
const input = Buffer.from(await obj.Body!.transformToByteArray());
// Validate by content, not by extension: Sharp throws on anything that is not an image.
const meta = await sharp(input).metadata();
console.log(JSON.stringify({ event: 'image_meta', key, format: meta.format, width: meta.width }));
for (const size of SIZES) {
const out = await sharp(input)
.rotate() // honour EXIF orientation
.resize({ width: size.width, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
await s3.send(new PutObjectCommand({
Bucket: process.env.DERIVED_BUCKET!,
Key: key.replace(/^uploads\//, `${size.name}/`).replace(/\.[^.]+$/, '.webp'),
Body: out,
ContentType: 'image/webp',
CacheControl: 'public, max-age=31536000, immutable',
}));
}
}
};
export const handler: SQSHandler = async (event) => {
const batchItemFailures: { itemIdentifier: string }[] = [];
for (const record of event.Records) {
try {
await processRecord(record);
} catch (err) {
console.error(JSON.stringify({ event: 'process_failed', messageId: record.messageId, err: String(err) }));
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures };
};
Points worth internalizing:
- Keys arrive URL-encoded. A file named
my photo.jpgarrives asmy+photo.jpg. Forgetting to decode produces aNoSuchKeyerror that looks like a permissions problem. - Never trust the extension or the declared content type. The presigned policy pins
Content-Type, but a client can still declareimage/pngand upload something else entirely. Decoding through Sharp is the validation; if it throws, the message goes to the DLQ and nothing downstream ever sees the bytes. - Derived output is immutable, so it can carry a one-year cache header safely.
- Idempotency: SQS delivery is at-least-once, and the derived key is a pure function of the source key, so a duplicate delivery rewrites identical output. That is idempotency by construction, which is far cheaper than a DynamoDB dedupe table. Prefer it whenever you can arrange it.
5. Files larger than a few hundred megabytes
Presigned POST works up to 5 GB, but a single browser POST of a 2 GB video will fail on any flaky connection. For large media, switch to multipart upload: a create endpoint returning an UploadId, a sign-part endpoint returning a presigned URL per part, and a complete endpoint. The AbortIncompleteMultipartUpload lifecycle rule above is what stops abandoned parts from accruing storage charges invisibly — that rule belongs on every bucket that accepts uploads, whether or not you use multipart today.
For files that need heavy transcoding, do not do it in Lambda. Have the processor submit a MediaConvert job or an ECS/Fargate task and let the 15-minute Lambda limit stop being your problem.
6. What to alarm on
Three CloudWatch alarms cover the realistic failure modes:
ApproximateNumberOfMessagesVisibleon the DLQ greater than 0 — something is being rejected and a human needs to look.ApproximateAgeOfOldestMessageon the main queue above 300 seconds — the processor cannot keep up, or is throttled.- Lambda
Throttlesgreater than 0 on the process function — raise reserved concurrency or lowerbatchSize.
Add a scheduled reconciliation job if uploads are business-critical: list the uploads/ prefix, check that each object has derived output, and re-enqueue anything orphaned. Notifications are reliable, not guaranteed.
Deploy and test
serverless deploy --stage dev
serverless logs -f process --stage dev --tail
In a second terminal, copy a sample image into the watched prefix and watch for the image_meta log line, then confirm thumb/ and medium/ objects exist in the derived bucket. Then upload a text file renamed to .jpg and confirm the message lands in the DLQ after five attempts rather than crashing the consumer.
Wrapping up
The pipeline is four moving parts — a signing endpoint, a locked-down bucket, a queue with a DLQ, and a stateless processor — and none of them ever puts a large payload through API Gateway. Get the queue policy, the visibility timeout, ReportBatchItemFailures, the lifecycle rules, and the separate output bucket right, and this design scales from ten uploads a day to ten thousand an hour without a code change.
We build this pattern regularly as part of Serverless Application Development and Serverless Integration Services. If you would like a review of an upload path that is already in production, get in touch.