Stop 22 · Build Backend Portfolio ProjectsProjects · 10/14

File Storage Service (S3-like)

Part of the Backend Developer Roadmap

In short

Profile photos, PDFs, resumes: every app stores files. Building a mini S3 teaches you to keep big bytes out of your API servers and still control exactly who can read what.

Your own simplified object storage service — practice for file handling, streaming, and the kind of infrastructure-adjacent thinking storage systems require.

The purple blob opens a small locker in a bank vault and places a folder inside.

Think of it like this

The bank locker room

The bank keeps a register: locker 214 belongs to Mrs Sharma. The valuables sit in the steel locker, not in the register. To open it you need the bank's permission and a key. Metadata in the register, bytes in the locker.

Watch it happen

Uploading with a presigned URL

Browser
API
Object store
Postgres

POST /files

1/7The browser asks to upload resume.pdf, 2 MB, as application/pdf.

The big ideas

What you need to know

Metadata vs bytes

Owner, name, size, type and status go in your database. The actual bytes go in object storage.

Presigned URLs

Short-lived signed links let clients upload and download directly, while you still decide who gets one.

Private by default

Buckets stay private. Every download goes through a permission check that hands out a fresh signed link.

Big files in parts

Multipart uploads send large files in chunks, so a dropped connection only retries one part.

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: "ap-south-1", endpoint: process.env.S3_ENDPOINT, forcePathStyle: true });6 7app.post("/files", requireAuth, async (req, res) => {8  const key = `users/${req.user.id}/${randomUUID()}`; // never use the client's filename as the key9  const uploadUrl = await getSignedUrl(10    s3,11    new PutObjectCommand({ Bucket: "uploads", Key: key, ContentType: req.body.contentType }),12    { expiresIn: 300 }, // valid for 5 minutes13  );14  await db.file.create({ data: { key, ownerId: req.user.id, status: "pending" } });15  res.status(201).json({ key, uploadUrl });16});

1/4An S3 client. With a custom endpoint it talks to MinIO or any S3-compatible store.

The same code works against AWS S3 or a local MinIO container: just change S3_ENDPOINT. Validate size and content type before signing.

Save yourself the pain

Common mistakes

Oops

Streaming every upload through your Node.js API server.

Do this instead

Big uploads tie up your server and memory. Let clients upload straight to storage with presigned URLs.

Oops

Making the bucket public so images load easily.

Do this instead

Public buckets leak private files. Keep it private and serve signed GET URLs or go through a CDN with signed access.

Your mission

Milestone 1: upload and download

~45 min

Quick check

What's the main benefit of presigned upload URLs?

Go deeper

  1. 1Support upload, download and delete operations for files of varying sizes.
  2. 2Stream large files instead of loading them fully into memory.
  3. 3Add access control so files can be private, public, or shared via signed URLs.

More projects 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