Tutorials / Backend integrations / Upload files to S3 or Cloudflare R2
📝 Written ● Intermediate Updated 2026-05-13

Upload files to S3 or Cloudflare R2

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.

S3 vs. R2 — pick one

0

Both speak the same API. The code below works on either; the only difference is which endpoint you point at.

  • AWS S3 — battle-tested, integrates with every other AWS service. Pricing: storage ~$0.023/GB/month, plus $0.09/GB egress. Egress is the killer; high-bandwidth apps get expensive fast. S3 pricing ↗.
  • Cloudflare R2 — S3-compatible, no egress fees. Storage ~$0.015/GB/month, zero egress. Perfect for serving images/files publicly. R2 pricing ↗.

For new projects: R2 unless you're already deep in AWS. The egress savings compound.

Create the bucket

1

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.

Get credentials

2

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

Install the SDK

3

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 ↗.

The client

4
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.

Pattern A: server-side upload (simple, slow)

5

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.

Pattern B: presigned URL (recommended)

6

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.

CORS — the thing that breaks first

7

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.

Serving files back

8

You stored the key in your DB. Two ways to serve it back:

Public read (free, simple):

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.

Validate uploads server-side

9

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:

  • Constrain the presign — set ContentType and ContentLength in PutObjectCommand. S3/R2 will reject uploads that don't match.
  • Verify after upload — your server does a 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.

Don't expose your access keys to the browser. Presigned URLs are the only safe pattern for browser uploads. If you find yourself instantiating new S3Client({ credentials }) in client-side code, stop — those keys go in the bundle, get scraped, and someone runs up your AWS bill.

Useful patterns

10

Official references

What's next