Skip to main content

☁️ Cloud Storage Integration

Your own server's disk is a bad place to keep user files: it fills up, it doesn't scale across multiple machines, and a crash can lose everything. Object storage services like Amazon S3 solve all three problems at once — practically infinite capacity, redundancy across data centers, and a global URL for every file. This lesson connects a Node app to S3 the modern way.

Week 10 · Wednesday (File Handling) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what object storage is and why it beats local disk for user files
  • Configure the AWS SDK for JavaScript v3 and its modular S3Client
  • Upload a buffer to S3 with PutObjectCommand and retrieve it later
  • Generate short-lived presigned URLs so private objects can be read without exposing the bucket
  • Implement direct-to-S3 uploads where the browser sends the file straight to the bucket
  • Keep credentials out of your code and follow least-privilege access

Estimated Time: 75 minutes

Practice: Build an upload endpoint that pushes a validated buffer to S3 and returns a presigned read URL, then add a presigned-upload route for direct browser uploads.

In This Lesson

Why Object Storage?

A traditional filesystem organizes data in a tree of folders. Object storage throws the tree away: every file (an "object") lives in a flat namespace inside a "bucket," addressed by a unique key. That simplification is what makes it scale to trillions of objects. You never think about disks, partitions, or free space — you just put objects and get them back.

Think of your local disk as a filing cabinet in your office: fast to reach, but finite, and gone if the building burns down. Object storage is a bank vault with unlimited safe-deposit boxes, mirrored across several cities. You trade a little latency for durability, capacity, and the ability to run many app servers that all share the same files.

graph LR U["User"] -->|"Upload"| A["App Server"] A -->|"Validate and PutObject"| S["S3 Bucket"] A -->|"Store key in DB"| DB[("Database")] U -->|"Later: request file"| A A -->|"Generate presigned URL"| S A -->|"Return short-lived URL"| U U -->|"Fetch bytes"| S

Amazon S3 is the most widely used implementation, and because so many other providers (Cloudflare R2, DigitalOcean Spaces, Backblaze B2, MinIO) copy its API, the code you learn here transfers to all of them by changing one endpoint line. We'll focus on S3 with the official AWS SDK v3.

Setting Up the S3 Client

The AWS SDK v3 is modular — instead of pulling in one giant aws-sdk package, you install only the clients and helpers you use. For S3 that means two small packages.

// npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

The SDK v3 uses a command pattern: you create a client once, then send it typed command objects like PutObjectCommand. This keeps each operation explicit and tree-shakeable.

const { S3Client } = require('@aws-sdk/client-s3');

// Create ONE client and reuse it across requests.
const s3 = new S3Client({
  region: process.env.AWS_REGION, // e.g. "us-east-1"
  // In production, OMIT credentials entirely — the SDK reads them from the
  // environment, an IAM role, or the AWS credentials file automatically.
  // Only pass them explicitly for local development:
  credentials: process.env.AWS_ACCESS_KEY_ID
    ? {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
      }
    : undefined
});

const BUCKET = process.env.AWS_S3_BUCKET;

module.exports = { s3, BUCKET };

📖 One line to switch providers

Because R2, Spaces, and MinIO speak the S3 API, you point the same client at them by adding an endpoint: new S3Client({ region: 'auto', endpoint: 'https://<account>.r2.cloudflarestorage.com', credentials }). Every command in this lesson then works unchanged.

Uploading & Reading Objects

Combine what you built last lesson — Multer memory storage with validation — with a PutObjectCommand. The buffer goes straight from the request to the bucket; your server's disk is never touched.

const express = require('express');
const multer = require('multer');
const crypto = require('crypto');
const { PutObjectCommand } = require('@aws-sdk/client-s3');
const { s3, BUCKET } = require('./s3');

const app = express();
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 10 * 1024 * 1024 } // 10 MB
});

app.post('/api/upload', upload.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'No file uploaded' });

  // Random, unguessable object key — same reasoning as safe filenames on disk.
  const key = `uploads/${crypto.randomUUID()}`;

  await s3.send(new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    Body: req.file.buffer,
    ContentType: req.file.mimetype,        // so browsers render it correctly
    ServerSideEncryption: 'AES256'         // encrypt at rest — free and easy
  }));

  // Persist the key (not a URL) in your database; URLs are generated on demand.
  res.status(201).json({ ok: true, key });
});

To read the object back on the server — say, to re-process it — you send a GetObjectCommand. Its Body is a readable stream you can pipe.

const { GetObjectCommand } = require('@aws-sdk/client-s3');

async function streamObjectToResponse(key, res) {
  const { Body, ContentType } = await s3.send(
    new GetObjectCommand({ Bucket: BUCKET, Key: key })
  );
  res.setHeader('Content-Type', ContentType);
  // Body is a Node Readable stream in the SDK v3 — pipe it, don't buffer it.
  Body.pipe(res);
}

⚠️ Don't proxy every file through your server

Piping objects back through your app works, but it puts every byte of every download on your server's bandwidth and CPU — exactly the load you moved to S3 to avoid. For most reads, hand the client a presigned URL instead and let it fetch directly from S3. That's next.

Presigned URLs

Best practice is to keep your bucket private — block all public access. But then how does a browser display a private image? The answer is a presigned URL: a normal S3 URL with a cryptographic signature and an expiry baked into the query string. Anyone holding the URL can perform exactly one operation (read this one object) for exactly as long as it's valid, then it stops working.

It's like a hotel key card. The front desk (your server, which holds the credentials) programs a card that opens one room until checkout. The guest never learns the master key; the card is useless tomorrow.

sequenceDiagram participant B as Browser participant Srv as App Server participant S3 as S3 Bucket B->>Srv: Ask for a link to view the file Srv->>Srv: Sign a GET URL with a short expiry Srv->>B: Return the presigned URL B->>S3: Fetch the object with the signed URL S3->>B: Return the file bytes Note over B,S3: After expiry the same URL is rejected
const { GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');

// Generate a read URL that self-destructs after 5 minutes.
async function getReadUrl(key) {
  const command = new GetObjectCommand({ Bucket: BUCKET, Key: key });
  return getSignedUrl(s3, command, { expiresIn: 300 }); // seconds
}

app.get('/api/files/:id/url', async (req, res) => {
  // In real code, look up the key by id AND check the user may access it.
  const key = `uploads/${req.params.id}`;
  const url = await getReadUrl(key);
  res.json({ url }); // the browser puts this straight into  or 
});

✅ Authorize before you sign

A presigned URL grants access to whoever asks your endpoint for it. The signing is not authorization — your route is. Always verify the requesting user is allowed to see that object before you call getSignedUrl, and keep expiry short so a leaked link ages out quickly.

Direct-to-S3 Uploads

So far the file still passes through your server on its way up. For large files and high traffic, you can cut the server out of the byte path entirely: sign an upload URL and let the browser PUT the file straight into the bucket. Your server does a few milliseconds of signing work instead of streaming gigabytes.

graph LR B["Browser"] -->|"1. Ask for upload URL"| Srv["App Server"] Srv -->|"2. Sign a PUT URL"| Srv Srv -->|"3. Return URL and key"| B B -->|"4. PUT file directly"| S3["S3 Bucket"] B -->|"5. Tell server it finished"| Srv Srv -->|"6. Save key in DB"| DB[("Database")]

Server: sign a PUT URL

const { PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');

app.post('/api/upload-url', express.json(), async (req, res) => {
  const { contentType } = req.body;

  // Constrain what can be uploaded: allowlist the content type up front.
  const ALLOWED = ['image/jpeg', 'image/png', 'image/webp'];
  if (!ALLOWED.includes(contentType)) {
    return res.status(400).json({ error: 'Unsupported content type' });
  }

  const key = `uploads/${crypto.randomUUID()}`;
  const command = new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    ContentType: contentType // the browser MUST send this exact type when it PUTs
  });

  const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
  res.json({ uploadUrl, key });
});

Browser: PUT the file, then confirm

async function uploadDirect(file) {
  // 1. Ask our server for a one-time signed PUT URL.
  const res = await fetch('/api/upload-url', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ contentType: file.type })
  });
  const { uploadUrl, key } = await res.json();

  // 2. Send the bytes straight to S3 — never through our server.
  const put = await fetch(uploadUrl, {
    method: 'PUT',
    headers: { 'Content-Type': file.type }, // must match what was signed
    body: file
  });
  if (!put.ok) throw new Error('Upload to S3 failed');

  // 3. Let our server record that the object now exists.
  await fetch('/api/upload-complete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key, name: file.name, size: file.size })
  });
  return key;
}

⚠️ CORS and the validation trade-off

Direct uploads need a CORS policy on the bucket allowing PUT from your site's origin. And because the bytes skip your server, you can't magic-byte-check them mid-flight — mitigate by constraining the signed ContentType, capping size with a policy, and validating asynchronously (e.g. an S3 event that triggers a check) after the upload lands.

Credentials & Access

The fastest way to a security incident is a leaked AWS key. Two rules keep you safe.

1. Never hard-code credentials

Access keys must never appear in your source, your Git history, or client-side code. In production, attach an IAM role to the machine or container and let the SDK pick the credentials up automatically — no keys in your app at all. For local dev, use environment variables loaded from a .gitignored .env file.

// .env  (git-ignored — NEVER commit this)
// AWS_REGION=us-east-1
// AWS_S3_BUCKET=my-app-uploads
// AWS_ACCESS_KEY_ID=...        ← local dev only
// AWS_SECRET_ACCESS_KEY=...    ← local dev only

require('dotenv').config();     // loads .env into process.env

2. Grant least privilege

The IAM user or role your app uses should be allowed to do only what it needs — put and get objects in one bucket — and nothing more. Don't attach AmazonS3FullAccess; scope a policy to the exact bucket and actions.

// An IAM policy scoped to one bucket and two actions.
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:PutObject", "s3:GetObject"],
    "Resource": "arn:aws:s3:::my-app-uploads/*"
  }]
}

💡 Block public access, always

Leave S3's "Block all public access" turned on and serve everything through presigned URLs. Public buckets are behind a huge share of real-world data leaks. If you truly need public assets (a site's logo), put them behind a CDN with a deliberate, reviewed policy — not by flipping the whole bucket public.

Practice & Quiz

🏋️ Exercise 1: Upload then presign

Goal: Write POST /api/upload that stores a validated buffer in S3 under a random key with server-side encryption, then returns a presigned read URL valid for 10 minutes.

💡 Hint

Use Multer memoryStorage for the buffer, PutObjectCommand to store it, and getSignedUrl(s3, new GetObjectCommand({...}), { expiresIn: 600 }) for the read link. Generate the key with crypto.randomUUID().

✅ Solution
const crypto = require('crypto');
const { PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { s3, BUCKET } = require('./s3');

app.post('/api/upload', upload.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'No file uploaded' });

  const key = `uploads/${crypto.randomUUID()}`;

  await s3.send(new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    Body: req.file.buffer,
    ContentType: req.file.mimetype,
    ServerSideEncryption: 'AES256'
  }));

  const url = await getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: BUCKET, Key: key }),
    { expiresIn: 600 }
  );

  res.status(201).json({ ok: true, key, url });
});

🏋️ Exercise 2: Delete an object

Goal: Add a DELETE /api/files/:key route that removes an object from the bucket. Use DeleteObjectCommand.

✅ Solution
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');

app.delete('/api/files/:key', async (req, res) => {
  // Authorize the caller for this object BEFORE deleting.
  await s3.send(new DeleteObjectCommand({
    Bucket: BUCKET,
    Key: `uploads/${req.params.key}`
  }));
  res.json({ ok: true });
});
// Note: DeleteObject succeeds even if the key doesn't exist, so a 200
// here means "the object is gone," not "it was definitely there."

🎯 Quick Quiz

Question 1: What is the main purpose of a presigned URL?

Question 2: In a direct-to-S3 upload, what does your server actually do?

Question 3: Where should your AWS credentials live in a production deployment?

Best Practices & Pitfalls

✅ Do

  • Keep buckets private with "Block all public access" on; serve reads via presigned URLs
  • Store the object key in your database and generate URLs on demand
  • Set ServerSideEncryption so objects are encrypted at rest
  • Use IAM roles in production and a scoped, least-privilege policy
  • Authorize the user before signing any URL

❌ Don't

  • Commit access keys, or ship them to the browser
  • Store a full presigned URL in your database — it expires; store the key
  • Proxy large downloads through your server when a presigned URL would do
  • Give a broad policy like AmazonS3FullAccess to an app user
  • Assume a direct-uploaded file is safe — constrain the signed type and validate afterward

⚠️ The expiring-URL bug

// ❌ Saving the signed URL means it breaks after expiresIn seconds.
await db.files.create({ url: await getReadUrl(key) });

// ✅ Save the durable key; sign a fresh URL each time it's needed.
await db.files.create({ key });

Presigned URLs are ephemeral by design. The key is the source of truth; the URL is a disposable ticket.

Summary

🎉 Key Takeaways

  • Object storage gives you scalable, durable, flat-namespace file storage — S3 is the standard
  • The AWS SDK v3 uses a reusable S3Client plus explicit command objects like PutObjectCommand
  • Presigned URLs grant short-lived, scoped access to private objects without exposing credentials
  • Direct-to-S3 uploads keep large-file traffic off your server — sign a PUT URL, let the browser upload
  • Never expose credentials; prefer IAM roles and least-privilege policies, and keep buckets private

📚 Additional Resources

🚀 What's Next?

Your files now live safely in the cloud — but they're the raw bytes the user uploaded, often huge and in the wrong format. Next we transform them on the way in: Image Processing with sharp, where you'll resize, convert to WebP, generate thumbnails, and strip metadata before storing.

🎉 Great work!

You've moved storage off your server and onto infrastructure that scales to any size — the same pattern behind every major file-heavy product you use.