Storing uploaded files from a Node.js and TypeScript backend in S3 or Google Cloud Storage with Encore, the AWS SDK or Multer, including presigned URLs, local buckets, tests and production.
A TypeScript backend can accept files easily enough, but storing them takes more decisions, since files belong in object storage such as S3 rather than on the server's disk.
Adding uploads means picking a client, choosing between proxying bytes through the API and issuing presigned URLs, creating buckets with the right access for each environment and having a bucket to develop against. This guide covers Encore, the AWS SDK for JavaScript and Multer with Express.
43.6% of respondents to the 2025 Stack Overflow Developer Survey had done extensive development work in TypeScript over the past year.
What you need to decide:
Encore combines the bucket in your code with the storage behind it in every environment. Object storage in Encore works with S3, Google Cloud Storage and other S3-compatible services, stores objects on the local filesystem during development and uses an in-memory backend in tests.
// uploads/uploads.ts
import { api, APIError } from "encore.dev/api";
import { Bucket } from "encore.dev/storage/objects";
export const avatars = new Bucket("avatars", { versioned: false });
interface UploadUrlRequest {
userId: string;
}
// Returns a signed URL the browser uploads the file to directly.
export const uploadUrl = api(
{ expose: true, method: "POST", path: "/avatars/upload-url" },
async ({ userId }: UploadUrlRequest): Promise<{ url: string }> => {
const { url } = await avatars.signedUploadUrl(`avatars/${userId}.png`, { ttl: 600 });
return { url };
},
);
// Small files can go through the API as raw bytes.
export const upload = api.raw(
{ expose: true, method: "PUT", path: "/avatars/:userId" },
async (req, resp) => {
const userId = new URL(req.url ?? "", "http://localhost").pathname.split("/").pop() ?? "";
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
await avatars.upload(`avatars/${userId}.png`, Buffer.concat(chunks), {
contentType: "image/png",
});
resp.writeHead(201);
resp.end();
},
);
export const downloadUrl = api(
{ expose: true, method: "GET", path: "/avatars/:userId/url" },
async ({ userId }: { userId: string }): Promise<{ url: string }> => {
const key = `avatars/${userId}.png`;
if (!(await avatars.exists(key))) throw APIError.notFound("no avatar");
const { url } = await avatars.signedDownloadUrl(key, { ttl: 3600 });
return { url };
},
);
The upload endpoint uses a raw endpoint to read the request body as a stream, and the file uploads guide covers multipart forms, while a bucket declared with public: true serves files through publicUrl.
On deploy the bucket is created as S3 in your AWS account or Cloud Storage on GCP, and an existing bucket can be imported. An agent asked to add attachments to a feature can declare the bucket next to the endpoint and run the flow under encore run without an AWS account, while cloud access sits with Encore's IAM role, not the agent.
Without Encore the code below needs a bucket per environment, an IAM policy that lets the service read and write it, CORS rules on the bucket so browsers can PUT to presigned URLs and a local stand-in for development.
The AWS SDK v3 splits S3 into @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner:
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({});
const Bucket = process.env.AVATAR_BUCKET!;
export async function uploadUrl(userId: string): Promise<string> {
return getSignedUrl(
s3,
new PutObjectCommand({ Bucket, Key: `avatars/${userId}.png`, ContentType: "image/png" }),
{ expiresIn: 600 },
);
}
export async function downloadUrl(userId: string): Promise<string> {
return getSignedUrl(s3, new GetObjectCommand({ Bucket, Key: `avatars/${userId}.png` }), {
expiresIn: 3600,
});
}
Presigned URLs created with the CLI last at most 7 days, and URLs signed with a role's temporary credentials expire with those credentials. A single PUT can carry up to 5 GB, and @aws-sdk/lib-storage handles multipart uploads above that.
Multer parses multipart/form-data in Express, and the multer-s3 storage engine streams each file to S3:
import express from "express";
import multer from "multer";
import multerS3 from "multer-s3";
import { S3Client } from "@aws-sdk/client-s3";
const upload = multer({
storage: multerS3({ s3: new S3Client({}), bucket: process.env.AVATAR_BUCKET! }),
limits: { fileSize: 5 * 1024 * 1024 },
});
const app = express();
app.post("/avatars", upload.single("avatar"), (req, res) => {
res.status(201).json({ key: (req.file as Express.MulterS3.File).key });
});
Every byte passes through the server, which is fine for avatars and less so for video, where a presigned URL avoids the load and request timeouts.
New S3 buckets block public access by default, so private files stay private until you add a policy, and public assets usually sit behind a CDN. With Encore the bucket and its permissions are created in your own account on deploy.
For small files, read the request body or use Multer with Express and send it to S3 with PutObjectCommand from @aws-sdk/client-s3. For large files, have the API return a presigned PUT URL from @aws-sdk/s3-request-presigner and let the browser upload directly. With Encore you declare a Bucket in your code and call upload or signedUploadUrl on it, and encore run stores objects locally.
A presigned URL created with the CLI or with IAM user credentials lasts at most 7 days. URLs signed with temporary credentials, such as those from an IAM role, expire when the credentials do, which is often sooner.
Run an S3-compatible server such as MinIO and point the SDK at it with a custom endpoint, or mock the client. With Encore, local development stores objects on the local filesystem and integration tests use an in-memory backend.
Directly to S3 for anything large, using a presigned URL, so the bytes do not pass through your servers and request timeouts do not apply. Through the API for small files that you want to validate or transform before storing.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.