Presigned-URL Uploads From a Serverless Function

The naive way to accept file uploads is to POST them to your API, let the server read the bytes, and write them to object storage. It works until the files get large or the traffic gets real. Now every upload crosses your infrastructure twice, once from the client to your server and once from your server to storage, and your server holds the whole file in memory or on disk while it does. On a serverless function it is worse, because functions have request-size and duration limits that a big upload runs straight into.
Presigned URLs are the standard fix, and they predate serverless by a decade. Your server does not move the bytes; it hands the client a short-lived, pre-authorized URL and the client uploads directly to object storage. The server only issues permission and records metadata. On a Neon Function this is the same AWS S3 SDK you already use, pointed at the branch's storage endpoint. This post builds it and tests the whole round trip. The repo is at the end.
TL;DR
- Proxying uploads through a function sends the bytes across it, burning bandwidth and memory and hitting request-size limits.
- A presigned URL is a time-limited, pre-authorized link to one object key. The client PUTs the bytes straight to storage; the function never touches them.
- On Neon Functions you generate it with
getSignedUrlfrom@aws-sdk/s3-request-presigner, the same code as any S3-compatible store. - I tested the full flow: presign, the client PUT straight to storage returned
200, a metadata record was saved, and downloading the object returned the exact bytes. - One gotcha to pin: the injected
AWS_REGIONis the storage-cell host, not a region, so setregion: 'us-east-2'on the client.
Prerequisites
- A Neon project on the platform preview with a declared bucket (object storage,
us-east-2) - The AWS SDK:
@aws-sdk/client-s3and@aws-sdk/s3-request-presigner - Familiarity with S3-style object storage and HTTP
PUT
Why not just proxy the upload
Sending the file through the function has three costs that all get worse with size:
- Bandwidth doubles. The bytes travel client → function → storage. You pay for both hops.
- The function holds the file. It buffers the body to forward it, so memory scales with upload size and concurrency.
- Limits bite. Serverless request-body caps and duration limits turn a large upload into a failed request, not a slow one.
The presigned pattern removes all three, because the large transfer never involves the function.
The flow
Hover the browser to see it in action: it talks to the function for a presigned URL and to write metadata, but the large transfer goes straight to object storage. Those function round trips are tiny JSON requests. The only large transfer, the bytes themselves, never touches your code.
The code
Issuing the URL is one call. getSignedUrl signs a PutObjectCommand with an expiry; the client then uses that URL as a plain HTTP PUT.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'node:crypto';
// The runtime injects the storage endpoint and credentials. Pin the region:
// the injected AWS_REGION is the storage-cell host, not a usable region.
const s3 = new S3Client({
region: 'us-east-2',
endpoint: process.env.AWS_ENDPOINT_URL_S3,
forcePathStyle: true,
});
// POST /files/presign -> { key, uploadUrl }
app.post('/files/presign', async (c) => {
const { filename, contentType } = await c.req.json();
const key = `uploads/${randomUUID()}-${filename}`;
const uploadUrl = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: 'files', Key: key, ContentType: contentType }),
{ expiresIn: 3600 }, // one hour
);
return c.json({ key, uploadUrl });
});
After the client uploads, it tells the function to record the file. That is an ordinary insert; the bytes are already in storage.
// POST /files/confirm -> saved row
app.post('/files/confirm', async (c) => {
const { key, filename, contentType, bytes } = await c.req.json();
const [row] = await db.insert(files).values({ key, filename, contentType, bytes }).returning();
return c.json(row, 201);
});
The three moving parts, from the browser's side, look like this:
The tested round trip
I ran the whole sequence against the deployed function. The key line is the direct PUT to storage returning 200 without the function in the path, and the downloaded object matching what was uploaded.
Downloads work the same way
The reverse direction is identical: presign a GetObjectCommand and either redirect the client to it or return it. The bytes stream from storage to the client, not through the function, and the URL expires. The demo's GET /files/:id does exactly that.
Presigned URLs are capability tokens. Two things to get right: keep expiresIn short (minutes, not days) so a leaked URL is not useful for long, and never presign a key taken raw from user input. Generate the key server-side (the demo uses a UUID prefix) so a caller cannot request a URL for someone else's object. If you need to cap upload size, presign with a content-length condition.
The repo
The full files API, direct upload plus this presigned flow, is here:
Wrapping up
Presigned URLs are one of those patterns that stay correct no matter where the code runs, and serverless is exactly where they pay off most, because the function's request limits make proxying large uploads a non-starter. On Neon Functions it is the same S3 SDK you already know, pointed at the branch's storage endpoint, with one config line to pin the region. The function hands out permission, the bytes go straight to storage, and your metadata stays in Postgres next to everything else.
We earn commissions when you shop through the links below.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
SMTPfast
Developer-first email API
Send transactional and marketing email through a clean REST API. Detailed logs, webhooks, and embeddable signup forms in one dashboard.
QuizAPI
Developer-first quiz platform
Build, generate, and embed quizzes with a powerful REST API. AI-powered question generation and live multiplayer.
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorTags
Found an issue?
Related Posts
Also worth your time on this topic
Compute That Lives on Your Database Branch
Neon Functions run your code in the same region as your Postgres, on a per-branch URL. To see why that matters I deployed a small API and timed a query from inside the function versus from a machine across the Atlantic: 1.2 ms against 135 ms. Here is how it works, with the real numbers and the repo.
Complete Web Server Automation with Ansible
Build a comprehensive Ansible playbook to automate web server deployment, configuration, and security hardening across multiple environments.
75 minutes