Stop 14 · Payments & Third-Party IntegrationsTopics · 3/6

File Storage (S3/Cloudinary)

Part of the Full Stack Developer Roadmap

In short

Your server's disk is a poor home for user files: it fills up and can vanish on redeploy. Cloud storage fixes that, and presigned URLs let the browser upload straight to it.

Storing user-uploaded files (avatars, documents, images) in dedicated cloud storage rather than on your application server's own disk, which doesn't survive redeploys or scale well.

The blob character pulls a hand cart of sacks to a warehouse gate, holding up a gate pass.

Think of it like this

The warehouse gate pass

A shop owner doesn't carry every sack through his shop to the godown. He hands the supplier a gate pass valid for ten minutes, and the truck unloads straight at the warehouse. A presigned URL is that gate pass: your API signs it, and the browser uploads directly to storage.

Watch it happen

Direct upload with a presigned URL

Browser
Your API
S3 bucket
Postgres

POST /uploads/sign

1/7The browser says it wants to upload an image/webp file.

The big ideas

What you need to know

Object storage, not a disk

S3, Cloudflare R2 and Cloudinary store files as objects by key. They scale and survive your server redeploying.

Presigned URLs

A short-lived signed URL lets the browser upload or download one object without ever seeing your cloud keys.

Cloudinary transforms

Cloudinary can resize, crop and convert images on the fly through the URL, which is handy for avatars and thumbnails.

Private by default

Keep buckets private. Serve public files through a CDN and private ones through signed download URLs.

See it in code

Walk through the code

ts
1import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";2import { getSignedUrl } from "@aws-sdk/s3-request-presigner";3import { randomUUID } from "node:crypto";4 5const s3 = new S3Client({ region: process.env.AWS_REGION });6const TYPES = ["image/jpeg", "image/png", "image/webp"];7 8app.post("/api/uploads/sign", requireUser, async (req, res) => {9  if (!TYPES.includes(req.body.contentType)) return res.status(400).json({ error: "Images only" });10  const key = `photos/${req.user.id}/${randomUUID()}`;11  const command = new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, ContentType: req.body.contentType });12  const url = await getSignedUrl(s3, command, { expiresIn: 300 });13  res.json({ url, key });14});15 16// Browser: await fetch(url, { method: "PUT", headers: { "Content-Type": file.type }, body: file });

1/4One S3 client for the app and an allow-list of image types.

AWS SDK v3. The bucket also needs a CORS rule allowing PUT from your site's origin, or the browser blocks the upload.

Save yourself the pain

Common mistakes

Oops

Making the whole bucket public so uploads 'just work'.

Do this instead

Keep it private, sign each upload, and serve files through a CDN or signed download links.

Oops

Assuming a presigned PUT limits the file size.

Do this instead

It doesn't. Use a presigned POST with a content-length-range condition, or check the object's size after upload.

Your mission

Upload without touching the server

~40 min

Quick check

With presigned uploads, which part sends the file's bytes to S3?

Go deeper

  1. 1Upload a file from your React frontend to S3 or Cloudinary via your backend.
  2. 2Generate a signed URL for private file access instead of making everything public.
  3. 3Store the resulting file URL in your database, not the file itself.

More topics at this stop

Stuck on this topic? Ask an Insider

Get 1:1 guidance from people who've walked this exact path — on the InsideEdge app.

Download
InsideEdge