Add file uploads to your app — profile pictures, attachments, exports, whatever. Same code works on AWS S3 and Cloudflare R2; pick the one that fits your wallet. Direct-from-browser uploads with presigned URLs are the default pattern.
Both speak the same API. The code below works on either; the only difference is which endpoint you point at.
For new projects: R2 unless you're already deep in AWS. The egress savings compound.
S3: Console → S3 ↗ → Create bucket. Pick a globally-unique name (lowercase, no underscores). Region close to your users.
R2: Cloudflare dashboard → R2 ↗ → Create bucket. Name only needs to be unique within your Cloudflare account.
Block public access by default. We'll serve files through your server or signed URLs.
S3: Create an IAM user with AmazonS3FullAccess (or scoped to the bucket). IAM console ↗ → Users → Create. Generate access keys.
R2: R2 dashboard → Manage R2 API Tokens → Create token. Pick "Object Read & Write" scoped to your bucket.
Either way you get two values: an Access Key ID and a Secret Access Key. Store in your backend env:
S3_ACCESS_KEY_ID=AKIAxxxxxxxxxxxxxxxx
S3_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
S3_BUCKET=my-app-uploads
S3_REGION=auto # R2 uses "auto"; S3 uses e.g. "us-east-1"
S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com # R2 only; omit for S3
Same package for both:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
For Python: pip install boto3. For Go: aws-sdk-go-v2 ↗.
const { S3Client } = require("@aws-sdk/client-s3");
const s3 = new S3Client({
region: process.env.S3_REGION,
endpoint: process.env.S3_ENDPOINT, // omit for AWS S3
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
});
This same client works for S3 and R2. The only difference is whether you set endpoint.
Browser sends file to your server; your server forwards to S3/R2. Easiest mental model, but every byte goes through your server (slow for big files, expensive for serverless).
const { PutObjectCommand } = require("@aws-sdk/client-s3");
const multer = require("multer");
const upload = multer({ storage: multer.memoryStorage() });
app.post("/api/upload", upload.single("file"), async (req, res) => {
const key = `uploads/${Date.now()}-${req.file.originalname}`;
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: req.file.buffer,
ContentType: req.file.mimetype,
}));
res.json({ key });
});
Works. Don't do this for files over ~10 MB or any high-traffic upload — it ties up your server's memory and bandwidth.
Your server signs a one-time URL that lets the browser upload directly to S3/R2. Your server never sees the file bytes.
const { PutObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
// 1) Browser asks your server for an upload URL
app.post("/api/upload-url", async (req, res) => {
const { filename, contentType } = req.body;
const key = `uploads/${Date.now()}-${filename}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: contentType,
}),
{ expiresIn: 60 * 5 } // 5 minutes
);
res.json({ url, key });
});
Then the browser:
// In the browser
async function uploadFile(file) {
const r = await fetch("/api/upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, contentType: file.type }),
});
const { url, key } = await r.json();
await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
return key; // your app stores this in the DB
}
One server round-trip to sign, then the upload goes direct. Files of any size; your server stays cheap and stateless.
Direct browser uploads require CORS on the bucket. Without this, the browser blocks the PUT.
S3: bucket → Permissions → CORS:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST", "GET"],
"AllowedOrigins": ["https://yourdomain.com", "http://localhost:3000"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
R2: bucket → Settings → CORS Policy. Same JSON. R2 CORS docs ↗.
If the browser console shows "blocked by CORS policy" — this is the file to edit.
You stored the key in your DB. Two ways to serve it back:
Public read (free, simple):
https://cdn.yourdomain.com/uploads/xxx.jpg — served free, globally.Signed read URLs (for private files):
const { GetObjectCommand } = require("@aws-sdk/client-s3");
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key }),
{ expiresIn: 60 * 60 } // 1 hour
);
Use for user-specific content (private docs, paid downloads). URL works once for the expiry window.
Presigned URLs trust the client. If you don't constrain things, a user could upload a 5 GB executable as "profile.jpg." Two practical guardrails:
ContentType and ContentLength in PutObjectCommand. S3/R2 will reject uploads that don't match.HEAD request on the key after the client says it's done. Confirm size, MIME, maybe inspect the bytes.For images specifically, sharp ↗ in a post-upload Lambda/Worker can resize + sanitize.
new S3Client({ credentials }) in client-side code, stop — those keys go in the bundle, get scraped, and someone runs up your AWS bill.
XMLHttpRequest instead of fetch for upload progress events. Or Uppy ↗ for a full-featured uploader UI.