"Just use S3" is good advice for storing bytes and misleading advice for handling uploads, because the bucket is maybe a tenth of what ends up existing. Here is the rest of the list, from projects that took the route.
What gets built around the bucket
An upload endpoint. Multipart parsing, a size limit, and a type check that reads the bytes rather than trusting the filename, because invoice.png can be a PDF and photo.jpg can be an HTML file with a script in it.
A key scheme. Which prefix, which name, how collisions are avoided, and whether the key leaks the user's email or the original filename. You will change this once, and then write a migration for the files that used the old one.
A resizing step. sharp, and therefore a native dependency in your build, in your Docker image, and in whatever CI machine builds it.
Somewhere to run it. In-request is simplest and blocks your web dyno on a 20MB photo. A queue is better and is now a queue: a broker, a worker, retries, a dead letter, and the state machine for "uploaded but not yet processed".
Thumbnails for the things that are not images. A PDF's first page needs a renderer, which is another binary in the image.
A policy. Public read for one prefix, private for another, write for your app only, and the yearly rediscovery of what "block public access" does.
A CDN. With its cache headers, its invalidation story, and a decision about whether URLs are stable or versioned.
Cleanup. Deleting a file means deleting its variants, and knowing what its variants were. Miss one and you pay for it monthly, forever, silently.
A dashboard, eventually. Not for you, for the person in support who needs to see what a customer uploaded, or delete it because they asked. Otherwise every such request is a database query you run by hand.
Each item is a day or two. None is interesting. Together they are the thing you maintain instead of your product, and they need attention again every time a dependency has a CVE.
What an upload API replaces
The endpoint, the key scheme, the resizing, the place to run it, the thumbnails, the policy, the CDN and the dashboard. What is left on your side is a route that forwards the file with your key attached, and a column holding the id you got back.
const body = new FormData();
body.append("file", req.file);
const res = await fetch("https://filemon.io/api/m1o900k8", {
method: "POST",
body,
headers: { Authorization: `Bearer ${process.env.FILEMON_KEY}` },
});
const { id } = await res.json();The honest trade
You are renting judgement, not just compute. The service decided the key scheme, the transformations happen when it says, and the limits are its limits. If your requirements are unusual, that will chafe, and you will end up building the list above anyway with a wasted detour behind you.
The question is not which is cheaper per gigabyte, because the API is more expensive per gigabyte, always. It is whether the list above is work you want to own. For a team whose product is not file handling, it usually is not. For a team that already runs infrastructure and has strong opinions about storage, it usually is.