{"course":{"slug":"building-apps-with-ai","title":"Building Apps with AI","url":"https://addaly.com/learn/building-apps-with-ai","level":"beginner","track":"build"},"lesson":{"slug":"files-and-uploads","title":"Files and uploads, and where they must not go","url":"https://addaly.com/learn/building-apps-with-ai/files-and-uploads","position":54,"of":87,"minutes":8,"answer":"Uploaded files belong in object storage with only their address in the database, because the app's own disk is temporary and a table of blobs slows every query that touches it.","keyIdea":"Uploaded files belong in object storage with only their address in the database, because the app's own disk is temporary and a table of blobs slows every query that touches it."},"body":{"format":"markdown","text":"## Three places a file could go, and only one is right\n\n**The repository.** Never. Uploaded files are user data; they would bloat every clone and be visible to anyone who can read the repo.\n\n**The app's own disk.** The model's first answer, `fs.writeFile(\"./uploads/photo.jpg\")`, because it works on the laptop. On a serverless host the disk is part of the process, and the process is recycled. The file exists for minutes or hours and then the process is gone and so is it. On a persistent host it survives until you redeploy, at which point the new build starts from the repository and the folder is empty. Either way, it is temporary and looks permanent for exactly long enough to ship.\n\n**The database, as a blob.** Works, and a table with a thousand 3 MB rows is a 3 GB table; every backup, every full scan, and every `SELECT *` that touches it drags the bytes along. Free tiers have storage limits in the low gigabytes. Keep it for tiny things (an avatar under 50 KB, perhaps) and nothing larger.\n\n**Object storage.** A service whose only job is to hold files and hand them back by address. The database stores the address. This is what every production app does.\n\n## Object storage, and the free ones\n\nAmazon S3 defined the shape; almost everything else speaks its protocol. Cloudflare R2 gives 10 GB free and charges nothing for downloads, which is the cost that surprises people on S3. Supabase Storage bundles 1 GB with its free database. Backblaze B2 gives 10 GB free. Any of them, with any S3-compatible client library, and the code is the same.\n\nThe unit is a **bucket** with **keys** (paths, in effect) and a file is put there once and read many times.\n\n## Two ways to upload\n\n**Through your server.** The browser sends the file to your route, your route sends it to the bucket. Simple, and every byte passes through your function, which on serverless has a body size limit, 4.5 MB on Vercel's default, and a time limit. Fine for avatars; wrong for a 200 MB video.\n\n**Directly, with a signed URL.** Your server generates a one-time upload address that permits exactly one key, one size limit, one content type, valid for a few minutes. The browser uploads straight to the bucket. Your server never touches the bytes, only the permission:\n\n```typescript\nconst url = await getSignedUrl(s3, new PutObjectCommand({\n  Bucket: \"uploads\",\n  Key: `${session.userId}/${randomUUID()}.jpg`,\n  ContentType: \"image/jpeg\",\n  ContentLength: size,        // refused if the browser sends more\n}), { expiresIn: 300 });\n```\n\nNote the key: the user's id is in the path, generated by the server, so a user cannot choose to write over someone else's file. The filename the browser offered is not used for anything but display; it is user input and it may contain `../` or a script tag.\n\n## Serving them back\n\nPublic files (product images) get a public URL from the bucket, ideally behind the provider's CDN. Private files (a customer's invoice) get a signed download URL, generated by your server after it has checked that this session is allowed to see that file. Making a bucket public because \"the URLs are unguessable\" is protection by obscurity; a leaked link is a permanent leak.\n\n## Images want resizing\n\nA phone photo is 4,000 pixels wide and 5 MB. Nobody's screen needs that in a 200-pixel avatar. Resize on the way in with a library such as `sharp` (one call, produces a few sizes) or on the way out with an image service; hosts and Cloudflare both offer one. An app that serves original photos in a list of fifty users sends 250 MB to load one page, and the person on a phone in a train will not wait.\n\n## The limits to set\n\nA maximum size, checked on the server (the browser check is a courtesy, not a control). A list of allowed types, checked by reading the file's first bytes rather than its extension: a file named `photo.jpg` can contain anything, and the security block returns to why that matters. A per-user quota, if storage costs you anything.\n\n## What the free path looks like end to end\n\nR2 or Supabase Storage for the bucket, a signed upload URL from a route that checks the session, `sharp` for a thumbnail, the key stored in a column of your own table with `user_id` beside it, and a signed download URL for anything private. Every piece of that is free at a small app's scale, and the model can write all of it once you have named the pieces."},"check":{"question":"Profile photos are saved with fs.writeFile to an uploads folder inside the project. Locally they persist. On the deployed site they show for a while and then return 404. Why?","options":[{"text":"The host's filesystem is wiped when the process is recycled, so the folder exists only for the life of that instance.","correct":true},{"text":"The uploads folder was excluded by .gitignore, so the files were never included in the deployment and the host has nothing to serve for them.","correct":false,"tempting":"The photos were written after deployment, not before; git never had them and was never meant to."},{"text":"Image files need a CDN to be served.","correct":false,"tempting":"A CDN speeds up serving; its absence produces slow images, not vanishing ones."},{"text":"The free tier limits the number of files that can be stored.","correct":false,"tempting":"Nothing is counting files; the disk itself is not durable."}]},"prev":{"slug":"sessions-cookies-and-who-you-are","title":"Sessions, cookies, and who the server thinks you are","url":"https://addaly.com/learn/building-apps-with-ai/sessions-cookies-and-who-you-are"},"next":{"slug":"backups-and-restore-once","title":"Backups, and restoring one before you need to","url":"https://addaly.com/learn/building-apps-with-ai/backups-and-restore-once"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Files and uploads, and where they must not go', in Building Apps with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-apps-with-ai/files-and-uploads (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Files and uploads, and where they must not go","courseTitle":"Building Apps with AI","module":{"index":6,"slug":"the-parts-that-persist","title":"The parts that persist: data, accounts and files"},"answer":null,"keyIdea":"Uploaded files belong in object storage with only their address in the database, because the app’s own disk is temporary and a table of blobs slows every query that touches it.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"three-places-a-file-could-go-and-only-one-is-right","runs":[{"text":"Three places a file could go, and only one is right"}]},{"t":"p","runs":[{"text":"The repository.","b":true},{"text":" Never. Uploaded files are user data; they would bloat every clone and be visible to anyone who can read the repo."}]},{"t":"p","runs":[{"text":"The app’s own disk.","b":true},{"text":" The model’s first answer, "},{"text":"fs.writeFile(\"./uploads/photo.jpg\")","code":true},{"text":", because it works on the laptop. On a serverless host the disk is part of the process, and the process is recycled. The file exists for minutes or hours and then the process is gone and so is it. On a persistent host it survives until you redeploy, at which point the new build starts from the repository and the folder is empty. Either way, it is temporary and looks permanent for exactly long enough to ship."}]},{"t":"p","runs":[{"text":"The database, as a blob.","b":true},{"text":" Works, and a table with a thousand 3 MB rows is a 3 GB table; every backup, every full scan, and every "},{"text":"SELECT *","code":true},{"text":" that touches it drags the bytes along. Free tiers have storage limits in the low gigabytes. Keep it for tiny things (an avatar under 50 KB, perhaps) and nothing larger."}]},{"t":"p","runs":[{"text":"Object storage.","b":true},{"text":" A service whose only job is to hold files and hand them back by address. The database stores the address. This is what every production app does."}]},{"t":"h2","id":"object-storage-and-the-free-ones","runs":[{"text":"Object storage, and the free ones"}]},{"t":"p","runs":[{"text":"Amazon S3 defined the shape; almost everything else speaks its protocol. Cloudflare R2 gives 10 GB free and charges nothing for downloads, which is the cost that surprises people on S3. Supabase Storage bundles 1 GB with its free database. Backblaze B2 gives 10 GB free. Any of them, with any S3-compatible client library, and the code is the same."}]},{"t":"p","runs":[{"text":"The unit is a "},{"text":"bucket","b":true},{"text":" with "},{"text":"keys","b":true},{"text":" (paths, in effect) and a file is put there once and read many times."}]},{"t":"h2","id":"two-ways-to-upload","runs":[{"text":"Two ways to upload"}]},{"t":"p","runs":[{"text":"Through your server.","b":true},{"text":" The browser sends the file to your route, your route sends it to the bucket. Simple, and every byte passes through your function, which on serverless has a body size limit, 4.5 MB on Vercel’s default, and a time limit. Fine for avatars; wrong for a 200 MB video."}]},{"t":"p","runs":[{"text":"Directly, with a signed URL.","b":true},{"text":" Your server generates a one-time upload address that permits exactly one key, one size limit, one content type, valid for a few minutes. The browser uploads straight to the bucket. Your server never touches the bytes, only the permission:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"const url = await getSignedUrl(s3, new PutObjectCommand({\n  Bucket: \"uploads\",\n  Key: `${session.userId}/${randomUUID()}.jpg`,\n  ContentType: \"image/jpeg\",\n  ContentLength: size,        // refused if the browser sends more\n}), { expiresIn: 300 });"},{"t":"p","runs":[{"text":"Note the key: the user’s id is in the path, generated by the server, so a user cannot choose to write over someone else’s file. The filename the browser offered is not used for anything but display; it is user input and it may contain "},{"text":"../","code":true},{"text":" or a script tag."}]},{"t":"h2","id":"serving-them-back","runs":[{"text":"Serving them back"}]},{"t":"p","runs":[{"text":"Public files (product images) get a public URL from the bucket, ideally behind the provider’s CDN. Private files (a customer’s invoice) get a signed download URL, generated by your server after it has checked that this session is allowed to see that file. Making a bucket public because “the URLs are unguessable” is protection by obscurity; a leaked link is a permanent leak."}]},{"t":"h2","id":"images-want-resizing","runs":[{"text":"Images want resizing"}]},{"t":"p","runs":[{"text":"A phone photo is 4,000 pixels wide and 5 MB. Nobody’s screen needs that in a 200-pixel avatar. Resize on the way in with a library such as "},{"text":"sharp","code":true},{"text":" (one call, produces a few sizes) or on the way out with an image service; hosts and Cloudflare both offer one. An app that serves original photos in a list of fifty users sends 250 MB to load one page, and the person on a phone in a train will not wait."}]},{"t":"h2","id":"the-limits-to-set","runs":[{"text":"The limits to set"}],"role":"limit"},{"t":"p","runs":[{"text":"A maximum size, checked on the server (the browser check is a courtesy, not a control). A list of allowed types, checked by reading the file’s first bytes rather than its extension: a file named "},{"text":"photo.jpg","code":true},{"text":" can contain anything, and the security block returns to why that matters. A per-user quota, if storage costs you anything."}]},{"t":"h2","id":"what-the-free-path-looks-like-end-to-end","runs":[{"text":"What the free path looks like end to end"}]},{"t":"p","runs":[{"text":"R2 or Supabase Storage for the bucket, a signed upload URL from a route that checks the session, "},{"text":"sharp","code":true},{"text":" for a thumbnail, the key stored in a column of your own table with "},{"text":"user_id","code":true},{"text":" beside it, and a signed download URL for anything private. Every piece of that is free at a small app’s scale, and the model can write all of it once you have named the pieces."}]}],"check":{"question":[{"text":"Profile photos are saved with fs.writeFile to an uploads folder inside the project. Locally they persist. On the deployed site they show for a while and then return 404. Why?"}],"options":[{"text":[{"text":"The host’s filesystem is wiped when the process is recycled, so the folder exists only for the life of that instance."}],"correct":true,"tempting":null},{"text":[{"text":"The uploads folder was excluded by .gitignore, so the files were never included in the deployment and the host has nothing to serve for them."}],"correct":false,"tempting":[{"text":"The photos were written after deployment, not before; git never had them and was never meant to."}]},{"text":[{"text":"Image files need a CDN to be served."}],"correct":false,"tempting":[{"text":"A CDN speeds up serving; its absence produces slow images, not vanishing ones."}]},{"text":[{"text":"The free tier limits the number of files that can be stored."}],"correct":false,"tempting":[{"text":"Nothing is counting files; the disk itself is not durable."}]}],"order":[2,0,1,3],"why":null},"prev":{"slug":"sessions-cookies-and-who-you-are","title":"Sessions, cookies, and who the server thinks you are","minutes":8},"next":{"slug":"backups-and-restore-once","title":"Backups, and restoring one before you need to","minutes":7}}}