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.

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
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
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
Quick check
With presigned uploads, which part sends the file's bytes to S3?
Go deeper
- 1Upload a file from your React frontend to S3 or Cloudinary via your backend.
- 2Generate a signed URL for private file access instead of making everything public.
- 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.