Serve images and user uploads from object storage
Move media out of your app server: public assets from a public-read bucket, private files through signed links, and direct browser uploads with CORS.
Storing uploads on the app server’s own disk works until you add a second server, rebuild the first, or run out of space. Putting files in object storage fixes all three. Here are the three patterns most web apps need.
Pattern 1: public assets
For files anyone may see, such as product photos, logos and downloads, use a bucket with Public read access. Every file then has a stable URL:
https://<bucket>.<host>/<file>
https://<host>/<bucket>/<file>
Listing the bucket stays private; people can only fetch files whose name they know. Set Cache-Control on files that never change (for example public, max-age=31536000 for versioned file names) so browsers keep them instead of downloading them again. You can edit it per file in the bucket browser.
Downloads from a public bucket count as your egress. Keep large files you do not want hot-linked in a private bucket.
Pattern 2: private files with signed links
Invoices, user documents and paid downloads belong in a private bucket. When a signed-in user asks for one, your app creates a short-lived signed URL and redirects them to it. The file goes straight from storage to the browser, and the link stops working after the time you set.
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "acme-private", "Key": f"invoices/{invoice_id}.pdf"},
ExpiresIn=300, # seconds
)
return redirect(url)
This is the same mechanism as the Share link button in the console, used from your own code.
Pattern 3: upload straight from the browser
Large uploads should not pass through your app server. Instead, your app signs an upload URL and the browser sends the file directly to the bucket.
upload_url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "acme-uploads", "Key": f"users/{user_id}/{filename}", "ContentType": content_type},
ExpiresIn=600,
)
await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
Because the browser talks to a different domain, the bucket needs a CORS rule. Open the bucket’s settings, then CORS rules, and allow your site:
[
{
"allowedOrigins": ["https://www.example.com"],
"allowedMethods": ["PUT", "GET"],
"allowedHeaders": ["*"],
"exposeHeaders": ["ETag"],
"maxAgeSeconds": 3000
}
]
You can fill in the same fields in the form instead of the JSON view.
Keys and limits
Give the app its own access key with read and write on its buckets only, and nothing else.
Set a size limit on upload buckets so a bug or abuse cannot fill them without you hearing about it.
Add a lifecycle rule on prefixes such as tmp/ to delete abandoned uploads after a few days.