Lesson 11 · 20 min read
S3 and CloudFront: Static Assets and CDN
Uploads, images and built assets in S3, served from edge locations near your users.
What you'll learn in this lesson
- Concept: file storage and file delivery
- Why files do not belong on the server
- How S3 thinks: buckets, keys, objects
- Private by default — and keeping it that way
- Create a bucket and use it
- Letting the app read and write it (no keys)
- Presigned URLs — uploads that skip your server
- CORS, the error every browser upload hits
- Versioning, lifecycle and storage classes
- What a CDN is and why it is fast
- CloudFront in front of S3, step by step
- Cache control, cache busting and invalidation
- Using it from Next.js
- What this costs
- Troubleshooting table
- Interview corner
- Practice task before Lesson 12
- Conclusion
Concept
This lesson covers two tools that work as a pair. Each one solves a different problem:
| S3 (Simple Storage Service) | CloudFront | |
|---|---|---|
| Problem solved | Where do files live? | How do files reach users fast? |
| Analogy | One giant, safe warehouse | Small local shops that stock copies of popular items |
| Location | One region (Mumbai). A region is a group of AWS data centres in one area. | Hundreds of edge locations worldwide. An edge location is a small data centre close to users. |
| Holds | The original of every file | Temporary cached copies |
Here is the rule for choosing between S3 and RDS (RDS is the AWS managed database service: AWS runs the database server for you): if it is a file (an image, a PDF, a video, a backup), it goes in S3. If it is a record you search (a user, an order), it goes in the database. The database stores the address of the file (the S3 key), not the file itself.
Why files do not belong on the server
On Vercel you may have used its blob storage, or you may have put images in /public (the Next.js folder for plain files that are served as they are). On your own server the easy way is a folder such as /uploads. This works until one of these things happens:
- You run two servers. A customer uploads a PDF to server A. The next request goes to server B, which has never seen the file. The file “vanished”. This is the stateless rule from Lesson 8, applied to files. (A stateless server keeps no data of its own between requests.)
- A server is replaced. Auto Scaling (Lesson 12) is an AWS service that adds and removes servers as traffic changes. It removes servers when traffic drops. Every upload on a removed server disappears with its disk.
- The disk fills. EBS is the virtual hard disk of an EC2 server. A 20 GB disk is full after a few thousand good photos, and making it bigger is extra work.
- Your CPU serves files. Every image request keeps a Node or Nginx worker busy. That worker should be running your app logic.
- Distance. A user in Delhi loading images from a server in Mumbai is fine. A user in London is much slower, and you cannot make light travel faster.
S3 gives you storage with no practical limit and no disk to manage. It keeps copies of your data in at least three Availability Zones (separate groups of data centres). It has 99.999999999% (eleven nines) durability, which means a very small chance of ever losing an object. The price is about $0.025 per GB per month in Mumbai.
How S3 thinks: buckets, keys, objects
S3 looks like a folder tree in the console, but it is not a file system. It is a giant key-value store. That means you give it a name (the key), and it gives you back the file (the value), like a huge dictionary:
| Word | Meaning | Example |
|---|---|---|
| Bucket | The top-level container. Its name is unique across all of AWS worldwide, and it lives in one region. | myapp-uploads-prod-7f3a |
| Object | One stored file plus its metadata. Up to 5 TB each. | a PDF |
| Key | The object's full name, slashes included. The “folders” are only a way to show the names. | tenants/acme/invoices/2026-01.pdf |
| Prefix | The start of a key. You use it to list files and to give permissions for a “folder”. | tenants/acme/ |
Two facts help you avoid real bugs. First, objects are immutable. You replace a whole object. You cannot edit part of it. Second, S3 has strong read-after-write consistency. Right after an upload, any read shows the new file.
A multi-tenant app is one app that serves many customers (tenants). Put the tenant in the key: tenants/<tenantId>/…. IAM is AWS's system for who may do what, and an IAM policy is a written permission rule in that system. Then one IAM policy or one presigned URL can be limited to one customer's prefix. Listing one customer's files is cheap, and deleting a customer means deleting one prefix. Never build a key from raw user input. Generate IDs with crypto.randomUUID() (a function that makes a random unique ID, called a UUID) and clean any names. This stops path tricks and accidental overwrites.
Private by default — and keeping it that way
Many famous cloud data leaks came from public S3 buckets. AWS has since made the safe choice the default. You should keep it that way.
New buckets block all public access, and ACLs (old per-object permission lists) are switched off. Also turn on S3 Block Public Access for the whole account (S3 → Block Public Access settings for this account). Then nobody on your team, and no future script, can make a bucket public by accident. Almost no modern setup needs a public bucket. Users see files through CloudFront, and CloudFront reads from a private bucket.
Who can read a private bucket, then?
- Your app, through its IAM role. A role is a set of permissions that a server can use without any stored password (next section).
- CloudFront, through a bucket policy (a permission rule attached to the bucket) that names the distribution. This is called Origin Access Control.
- One user for a short time, through a presigned URL (a temporary link, explained below).
Nobody else can read it, and the open internet cannot. That is what “private” means.
Create a bucket and use it
Go to S3 → Create bucket. Bucket names are shared by every AWS account in the world, so add something random, such as myapp-uploads-7f3a. Choose the region ap-south-1. Leave Block all public access switched on. Leave default encryption as it is. Both are already the safe choice. That is all.
From your laptop, the AWS CLI (the command-line tool for AWS) can copy files in and out, list a prefix, sync a whole folder, and delete. Try one command now. It shows the “private by default” idea at work:
aws s3 presign s3://myapp-uploads-7f3a/assets/logo.png --expires-in 300Upload any file in the console first. This link works for 5 minutes. The plain object URL is denied
Letting the app read and write it (no keys)
Lesson 5 created myapp-pdfs-rw, a policy that allows exactly get and put on one bucket. Now make a policy for your new bucket and attach it to the EC2 role. Notice the two different resources: the bucket itself, and the objects inside it. An ARN (Amazon Resource Name) is the unique ID of an AWS thing.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListOnlyThisBucket",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::myapp-uploads-7f3a"
},
{
"Sid": "ReadWriteObjects",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::myapp-uploads-7f3a/*"
}
]
}s3:ListBucket works on the bucket ARN (no slash at the end). s3:GetObject and PutObject work on the objects ARN (…/*). If you use the wrong ARN for an action, you get AccessDenied, even though the policy “looks right”.
Create this as a policy (myapp-uploads-rw) and attach it to myapp-ec2-role. You do not need to restart anything. The server gets its temporary credentials from AWS by itself, and a policy change takes effect quickly, usually within seconds. On the server, with no keys set up, listing your bucket now works. Listing any other bucket is denied.
Presigned URLs — uploads that skip your server
Here is a common beginner upload flow. The browser sends a 40 MB video to your Next.js API, and the API forwards it to S3. This keeps your server busy for the whole upload and uses double the bandwidth. It also hits body-size limits (Nginx 413, and 4.5 MB on Vercel functions).
The better pattern uses a presigned URL. This is a web link with a built-in signature (a proof of permission). It allows one action on one object for a short time. Your server only signs a permission slip, and the browser uploads straight to S3.
- Browser asks your API: “I want to upload
photo.jpg(image/jpeg).” - Your API checks that the user is logged in and allowed. It makes a key and returns a presigned PUT URL that works for a few minutes.
- The browser sends the file straight to S3 with a
PUTrequest (the HTTP method for “store this”), using that URL. - Your API stores the key in the database.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
// No credentials: the SDK (the AWS code library) finds the EC2 role by itself (Lesson 5)
const s3 = new S3Client({ region: "ap-south-1" });
const ALLOWED = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
export async function POST(req: Request) {
const user = await requireUser(req); // your own auth check — never skip this
const { contentType } = await req.json();
if (!ALLOWED.includes(contentType)) {
return NextResponse.json({ error: "File type not allowed" }, { status: 400 });
}
const key = `tenants/${user.tenantId}/uploads/${randomUUID()}`; // server decides the key
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.UPLOAD_BUCKET!,
Key: key,
ContentType: contentType, // the upload MUST send exactly this header
}),
{ expiresIn: 300 }, // 5 minutes
);
return NextResponse.json({ url, key });
}async function upload(file: File) {
const res = await fetch("/api/upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type }),
});
const { url, key } = await res.json();
const put = await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type }, // must match what was signed
body: file,
});
if (!put.ok) throw new Error("Upload failed");
return key; // save this in your database
}The URL contains a signature. This is a code worked out from your role's credentials plus the exact bucket, key, method and expiry time. S3 works out the code again when the request arrives. If anything is different (the key, the method, a header), the code does not match and the request fails. The link also stops working when the role's temporary credentials expire. It is like a key card that works for one job and for a short time. It is Lesson 5's plumber, for one file.
CORS, the error every browser upload hits
When the browser calls S3 directly, the browser's same-origin rule applies. An origin is the protocol, domain and port together, such as https://yourapp.com. A page from one origin may not use another origin (such as *.s3.amazonaws.com) unless that server agrees. CORS (Cross-Origin Resource Sharing) is the set of rules a server uses to say yes. Before the PUT, the browser sends a small check request (a preflight) to ask S3 whether it is allowed. Your first try will show “blocked by CORS policy”. The fix is a CORS configuration on the bucket:
{
"CORSRules": [
{
"AllowedOrigins": ["https://yourapp.com", "https://*.yourapp.com", "http://localhost:3000"],
"AllowedMethods": ["PUT", "GET", "HEAD"],
"AllowedHeaders": ["Content-Type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
}Paste it in the bucket's Permissions → CORS. List only origins that you own. Never use * for a bucket that accepts uploads. Remove the localhost origin from the production bucket when you no longer need it.
Versioning, lifecycle and storage classes
Versioning — the undo button
Versioning is a bucket setting that keeps every old version of an object. When you delete a file, S3 only adds a “delete marker” (a hidden note that makes the file look deleted). You can restore an overwritten or deleted file. This is the cheapest insurance for user data. It is also part of Lesson 18's backup story.
Both are in the console. Versioning is under Properties → Bucket versioning. A lifecycle rule is an automatic rule that acts on objects as they get older. Make one under Management → Create lifecycle rule with the three actions below.
This is what the lifecycle rule does:
- Old versions of files are deleted after 30 days. Without this, versioning grows forever.
- Half-finished uploads are cleaned up after 7 days. They are a common hidden cost.
- Files that nobody touches for 90 days move to a cheaper storage class automatically.
Storage classes
| Class | Use it for | Approx. price | Access |
|---|---|---|---|
| Standard | Anything you read often: images, uploads, app assets | ~$0.025/GB-month | Instant |
| Intelligent-Tiering | You do not know how often files are read. S3 moves objects to cheaper tiers for you | From $0.025 down to ~$0.004 per GB-month, plus a small monitoring fee | Instant |
| Standard-IA | Read rarely, but needed fast when you do (monthly reports, old invoices) | ~$0.0138/GB + retrieval fee | Instant |
| Glacier Instant Retrieval | Archives you almost never touch, but that must open in milliseconds | ~$0.005/GB + retrieval fee | Instant |
| Glacier Deep Archive | Archives kept for years, for example for legal rules | ~$0.002/GB + retrieval fee | Hours (standard retrieval: up to 12 hours; bulk: up to 48) |
Start with Standard. Add Intelligent-Tiering when you have a lot of data and do not know how often it is read. Do not make this complicated until storage is a big part of your bill. The prices in the table are rough and differ by region. Some classes also have a minimum storage time (for example 30 days for Standard-IA), so check the current pricing page.
What a CDN is and why it is fast
Latency is the delay before data arrives. It depends on distance (data cannot travel faster than light) and on the number of hops (steps between machines). A file stored in Mumbai takes about 20 ms to reach a user in Mumbai. It can take 100–300 ms to reach London or São Paulo. A web page needs dozens of files, so the delays add up. A Content Delivery Network (CDN) fixes this. It keeps copies of your files in hundreds of small data centres (edge locations) near users.
- User in Mumbai
- ↓Mumbai edge: not cached
- ↓S3 in ap-south-1 (origin)
- ↓edge stores a copy
- ↓user gets file — ~120 ms
- User in Mumbai
- ↓Mumbai edge: cached ✓
- ↓user gets file — ~15 ms
- ↓origin never touched
- ↓repeat until TTL expires
A cache is a saved copy that is kept for quick reuse. The origin is the original server (here, S3). The first visitor in a city causes a cache miss: the edge location has no copy, so it fetches the file from your origin. Everyone after that gets a cache hit: the edge answers from its copy. Your origin sees only a small part of the traffic. This is also why a CDN protects your server from traffic spikes.
CloudFront in front of S3, step by step
The flow is: users → CloudFront → (only on a miss) → private S3 bucket. A distribution is one CloudFront setup. The bucket allows only that one distribution to read it. This works through Origin Access Control (OAC), a CloudFront setting that signs CloudFront's requests to S3. The console is the easiest way to create a distribution the first time:
Create the distribution
Go to CloudFront → Create distribution. For Origin domain, pick your S3 bucket. For Origin access, choose Origin access control settings (recommended) and create a new OAC (signing: sign requests). Under Viewer protocol policy, choose Redirect HTTP to HTTPS. Turn on compression. Pick the cache policy CachingOptimized. Then click Create.
Apply the bucket policy CloudFront shows you
After you create the distribution, the console shows a ready-made policy. It looks like this. Read it carefully. It says “only this distribution may read objects”:
bucket policy for OAC{ "Version": "2012-10-17", "Statement": [{ "Sid": "AllowCloudFrontServicePrincipalReadOnly", "Effect": "Allow", "Principal": { "Service": "cloudfront.amazonaws.com" }, "Action": "s3:GetObject", "Resource": "arn:aws:s3:::myapp-uploads-7f3a/*", "Condition": { "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E1ABCDEF2GHIJK" } } }] }The
Conditionis the important part. Without it, any CloudFront distribution in the world could read your bucket. Paste the policy in the bucket's Permissions → Bucket policy.Test the two paths
Upload a test file. Then compare the direct S3 URL with the CloudFront URL:
Direct S3 is denied, CloudFront workscurl -I https://myapp-uploads-7f3a.s3.ap-south-1.amazonaws.com/assets/logo.pngDirect to S3 you get 403 Forbidden. This is correct, because the bucket is private
curl -I https://d1234abcd.cloudfront.net/assets/logo.pngThrough CloudFront you get 200 OK. Run it twice. The second answer says x-cache: Hit from cloudfront, which means it came from the edge
Use your own domain: assets.yourapp.com
The random name
d1234abcd.cloudfront.netis ugly, and it ties you to CloudFront. To useassets.yourapp.cominstead:- Request an ACM certificate for
assets.yourapp.cominus-east-1(N. Virginia). ACM is AWS Certificate Manager. CloudFront is a global service, and it reads certificates only from that one region. This is a common mistake, because everything else in this course is in Mumbai. - Add the name as an Alternate domain name on the distribution, and select the certificate.
- In Route 53 (the AWS DNS service, Lesson 13), create an alias A record from
assets.yourapp.comto the distribution.
- Request an ACM certificate for
Cache control, cache busting and invalidation
A cache has one hard problem: when the original file changes, how do the copies find out? There are three ways to handle this. The best way is to avoid the problem. Two terms first. The TTL (time to live) is how long a copy may be kept. The max-age setting in the Cache-Control header sets the TTL in seconds.
| Strategy | How | Verdict |
|---|---|---|
| Wait | Let the TTL (max-age) run out | Simple, but users see old (stale) files until it does |
| Invalidate | Create an invalidation for /assets/logo.png on the distribution. An invalidation is a request that tells CloudFront to throw away its copies of that path | Works. The first 1,000 paths each month are free, then $0.005 each. It takes a minute or two. Use it as a quick fix, not as a habit |
| Versioned file names | app.a1b2c3.js, logo-v7.png: a new name whenever the content changes | Best. You never need an invalidation, and files can be cached forever |
Next.js already does the third way for you. The file /_next/static/chunks/main-8f3a2c.js has a content hash in its name (a hash is a short code made from the file's content, so it changes when the content changes), so it can use Cache-Control: public, max-age=31536000, immutable. (immutable tells the cache that the file will never change.) For your own uploads, use the same trick. Never overwrite a key. Upload to a new UUID key and save the new key in the database.
A CDN is a shared cache. Some content must never be cached at the edge: HTML pages that are different for each user, API answers with private data, and anything behind a login. Otherwise one user can receive another user's page. Cache only static, public, versioned files. For everything else, send Cache-Control: private, no-store. Private user files (such as invoices) should use presigned URLs, not a public CDN path.
Using it from Next.js
There are two common ways to use the CDN. First, serve your built /_next/static files from it. You do this with assetPrefix, a setting that puts your CDN address in front of those file URLs. Next.js writes the new URLs into the HTML, so the files load from the edge instead of your server:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// Production only, so local development works without a CDN
assetPrefix: process.env.NODE_ENV === "production" ? "https://assets.yourapp.com" : undefined,
images: {
remotePatterns: [{ protocol: "https", hostname: "assets.yourapp.com" }],
},
};
export default nextConfig;Then upload the static build output as part of your deploy. This step belongs in the CI pipeline of Lesson 14. (CI/CD is a robot that checks, builds and ships your code.) Only the .next/static folder goes to the CDN:
aws s3 sync .next/static s3://myapp-uploads-7f3a/_next/static \
--cache-control "public, max-age=31536000, immutable"Do not upload the rest of the .next folder, because it holds your server code. Also note that assetPrefix does not cover files in the public folder. If you want those on the CDN, you must add the prefix yourself.
Upload the new static files before you switch the servers to the new build. If you do it the other way round, a user can get new HTML that points to a file that is not in S3 yet, and the page breaks for a minute. Keep the old files for a while too. The reason is the same, in the other direction: pages that are still open in browsers need the old files.
What this costs
| Item | Approx. price |
|---|---|
| S3 Standard storage | ~$0.025 per GB-month (10 GB ≈ $0.25) |
| S3 requests | ~$0.005 per 1,000 writes, ~$0.0004 per 1,000 reads |
| Data in to S3 | Free |
| S3 → CloudFront transfer | Free |
| CloudFront data out | First 1 TB/month free, then ~$0.11/GB in India |
| CloudFront requests | First 10 million/month free |
| ACM certificates | Free |
For a small company's image and file traffic, this is usually a few dollars a month. It is often cheaper than serving the same files from EC2, because data that leaves EC2 is billed, but data from S3 to CloudFront is free. Prices change, so check the current pricing pages.
Troubleshooting table
| Symptom | Likely cause | Fix |
|---|---|---|
AccessDenied on GetObject from the app | The instance role has no s3:GetObject permission for that bucket or prefix, or the ARN is missing the /* at the end | Check the Resource line. Use the bucket ARN for ListBucket and arn:…:bucket/* for object actions. |
403 from CloudFront for a file that exists | The bucket policy does not allow the CloudFront distribution (OAC), or the file key is wrong | Apply the OAC bucket policy again. Check the exact key and the upper and lower case letters. |
Browser: “blocked by CORS policy” on upload | The bucket CORS rules do not list your site's origin, method or headers | Add your origin, the PUT and GET methods, and the headers to the bucket's CORS rules |
Presigned URL: SignatureDoesNotMatch | The upload sends a different Content-Type (or header) than the one that was signed, or the URL has expired | Send exactly the signed Content-Type. Check the expiry time and the server clock. |
Old file still served after re-uploading | CloudFront keeps serving its saved copy until the TTL (the time the copy may be kept) ends | Use versioned file names, or create an invalidation for that path |
Distribution works, but custom domain shows a certificate error | The ACM certificate is not in us-east-1, is not validated yet, or does not cover the domain | Request (or import) it in N. Virginia (us-east-1) and attach it |
Cannot delete the bucket | It is not empty. Old versions and delete markers count too | Empty it with a lifecycle rule, or with aws s3 rm --recursive plus a clean-up of old versions |
Interview corner
How do you let users upload large files securely without going through your server?
The server checks who the user is. It returns a short-lived presigned PUT URL for one key and one content type. The browser uploads straight to S3 (the bucket CORS rules must allow it). The server saves the key. This removes size limits and server load.
How do you serve private S3 content through CloudFront?
I keep the bucket private with Block Public Access. I create a CloudFront distribution with Origin Access Control. Then I add a bucket policy that allows only that distribution (using a condition on AWS:SourceArn). For private files of one user, I use CloudFront signed URLs or signed cookies (a signed URL or cookie is a temporary proof of permission that CloudFront checks).
You replaced an image but users still see the old one. Why, and what is the right fix?
CloudFront (and browsers) keep serving the saved copy until the TTL runs out. A quick fix is an invalidation. The right design is file names with a content hash or a version, and a long max-age. Then a change gets a new URL.
Why must a CloudFront ACM certificate be in us-east-1?
CloudFront is a global service, and it reads its certificates from N. Virginia (us-east-1). Regional services such as an ALB use certificates from their own region.
S3 durability vs availability?
Durability (11 nines) is the chance that an object is not lost over a year. S3 stores the data in several places to achieve this. Availability (99.99% for Standard) is the chance that a request works at a given moment. They are different numbers with different meanings.
A cross-tenant data leak: customer A saw customer B's file. Where could S3 be the cause?
Possible causes are: keys that are easy to guess (in order, or chosen by the user), a presigned URL given out without checking that the tenant owns the file, a CDN path that cached a private answer, or an IAM policy wider than the tenant prefix. The fix is random keys with a tenant prefix, a permission check before signing, and no CDN caching of private content.
Why turn on versioning, and what is the catch?
It lets you recover from an accidental overwrite or delete, and from malicious changes. The catch is cost. Old versions pile up, so pair it with a lifecycle rule that expires old (noncurrent) versions.
Practice task before Lesson 12
- Create a private bucket with encryption and Block Public Access. Upload a file and prove the plain object URL returns 403 while a
presignURL works. - Attach the permission policy to
myapp-ec2-role. Then confirm, from the server with no keys, that you can list this bucket but not another one. - Create a CloudFront distribution with OAC. Confirm direct-to-S3 is 403 and the CloudFront URL returns
MissthenHit. - Build the
/api/upload-urlroute and a small page that uploads a file to S3. You will hit a CORS error first — fix it with the bucket CORS config. - Turn on versioning. Overwrite a file, then restore the earlier version in the console.
- Re-upload a changed
logo.pngto the same key and observe the CDN serving the stale copy. Solve it two ways: an invalidation, and by renaming tologo-v2.png. Which would you do in production?
Run curl -s -o /dev/null -w "%{time_total}\n" URL for the same file from S3 directly (with a presigned URL) and through CloudFront, several times each, and compare. If you have a friend in another country, ask them to try it too.
Conclusion
S3 stores files safely and cheaply. CloudFront delivers them quickly. Together they take the heaviest and most repeated work away from your servers. This makes the servers easy to replace.
- Files go in S3, records in RDS. The database keeps the key, not the file.
- Private by default: Block Public Access on the account; access through the IAM role, CloudFront OAC or a presigned URL — never a public bucket.
- Presigned URLs let the browser upload directly, which removes size limits and server load. Remember CORS.
- Version your file names, so a CDN can cache them forever and you never need to invalidate. Lifecycle rules keep the bill small.
- The ACM certificate for CloudFront must be in us-east-1. Remember this one, because it surprises many people.
Your data is now in RDS and your files are in S3, so your app servers are finally stateless. This must be true before the next lesson: running many servers behind a load balancer.
End of Lesson 11. Next: Lesson 12 — ALB and Auto Scaling: Scaling to 1000+ Concurrent Users.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related series