Written by Rodrigo de Miguel
S3 + CloudFront to serve a fast and cheap static blog (3/5)
How to serve a static blog with S3 and CloudFront using Astro, with clean URLs, solid caching, and near-zero cost—no overengineering, no magical platforms.
We don’t need much to serve HTML
At this point in the series, a few things are already on the table:
- An Astro blog
- Static HTML generated
- Zero dynamic parts
- Nothing to “compute” on each request
So the question comes naturally:
how do I serve these files in the simplest way possible, without dragging in extra infrastructure?
For my portfolio (and for this example), the answer was pretty obvious:
S3 + CloudFront.
It’s not “enterprise”.
It’s simple, cheap, and predictable.
And for a blog, that combo usually hits the mark.
Quick reality check: I don’t even know how long I’ll keep enjoying writing in this format. Right now it fits, and that’s enough.
How many blogs have been started… and quietly left behind?
What we’re going to build (and what we’re not)
Before getting our hands dirty, let’s set expectations.
What we will do
- Upload static HTML to S3
- Serve it through CloudFront (CDN)
- Keep clean URLs without
/index.html - “Automate” deployment with a single command
What we will NOT do
- Kubernetes
- Load balancers
- Backend
- Anything that doesn’t earn its place here
This is still a simple blog. No fireworks.
Architecture overview
Very short version:
| Piece | What it does |
|---|---|
| Astro | Generates HTML at build time |
| S3 | Stores static files |
| CloudFront | CDN + reverse proxy |
| (Route 53) | Covered in the next post |
With this setup, you can serve millions of requests without touching anything else.

Step 1 — Create the S3 bucket
I’ll assume you already have an AWS account.
In S3:
- Create a bucket with any name (we’ll pass it through env vars later)
- Region: whichever makes sense for you (I pick the one closest to my audience, but it’s not a deal-breaker)
Key configuration:
- ACLs disabled
- Block all public access
The idea is straightforward:
👉 no one talks to S3 directly, everything goes through CloudFront.
Step 2 — Create the CloudFront distribution
This is where we put the “face” of the blog.
In CloudFront:
- Distribution type: Single website or app
- Domain: leave it empty (custom domain comes later)
Origin
- Origin type: Amazon S3
- Bucket: the one you just created
- Enable: Allow private S3 bucket access to CloudFront
- CloudFront will suggest creating an Origin Access Control (OAC)
→ accept it, it’s the cleanest route
This way:
- CloudFront can read from the bucket
- Nobody else can
Step 3 — Basic distribution settings
Once the distribution is up, check the basics:
Security
- Enable Core protections
- WAF in monitor mode (more than enough for this use case)
Behavior (defaults work well)
- Cache policy:
CachingOptimized - Origin request policy:
CORS-S3Origin - Response headers:
CORS-and-SecurityHeadersPolicy
Error Pages
- Add a custom 404 error page:
-
Error code 404 →
/404.html→ HTTP response code: 200 -
Error code 403 →
/404.html→ HTTP response code: 200Sometimes S3 returns 403 instead of 404 for non-existent routes
-
There’s not much else worth touching right now.
Step 4 — Allow CloudFront to read from the bucket
CloudFront still needs explicit permission to read from S3.
In the bucket:
- Permissions → Bucket policy
Simple example:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "YOUR_DISTRIBUTION_ARN"
}
}
}
]
}This keeps things clear:
- The bucket stays private
- Only your distribution can read it
Checkpoint: what we have so far
At this stage:
- S3 is ready to receive HTML
- CloudFront sits in front
- Infra is protected
- Nothing is public unless it goes through the CDN
Next step: upload the files.
Step 5 — Simple deployment with a single command
To get started, no CI/CD pipelines.
Just a direct script in package.json:
"deploy": "aws s3 sync dist/ s3://$BLOG_BUCKET_S3 --delete --region YOUR_BUCKET_REGION --cache-control max-age=31536000 && aws cloudfront create-invalidation --distribution-id $BLOG_DISTRIBUTION_ID --paths '/*'"What this does
-
aws s3 sync- Uploads files from
dist/ - Removes what no longer exists
- Sets aggressive caching (great for assets)
- Uploads files from
-
CloudFront invalidation
- Makes sure changes show up right away
- Could be more granular, but this is fine for now
- If you have high traffic, you can invalidate only the
/index.htmlfiles.
Run it
$ npm run build
$ BLOG_BUCKET_S3=YOUR_BUCKET_NAME BLOG_DISTRIBUTION_ID=YOUR_DISTRIBUTION_ID npm run deployAfter that:
- Files live in S3
- CloudFront serves them worldwide
Step 6 — The ugly detail: /index.html
If you try the distribution URL:
https://xxxxx.cloudfront.net/index.html
It works.
But:
https://xxxxx.cloudfront.net/blog/
It doesn’t.
And having to type /index.html feels… off.
Let’s clean that up.
Step 7 — Clean URLs with CloudFront Functions
Here we add a tiny CloudFront Function.
In CloudFront → Functions:
- Name:
append-index-html-to-request-astro-app - Runtime:
cloudfront-js-2.0
Code:
function handler(event) {
var request = event.request;
var uri = request.uri;
// Assets: js, css, images, etc.
if (uri.includes('.')) return request;
if (uri === '/') {
request.uri = '/index.html';
return request;
}
if (uri.endsWith('/')) {
request.uri = uri + 'index.html';
return request;
}
request.uri = uri + '/index.html';
return request;
}Publish the function and associate it:
- Associated distributions > Add Association
- Select your distribution
- Event: Viewer Request
- Behavior: Default (*)
This gives you:
- Clean URLs
- Correct HTML serving
- No build changes
Optional detail: hide the server header
Not critical, but I like keeping things tidy.
Another CloudFront Function on Viewer Response:
function handler(event) {
var response = event.response;
var headers = response.headers;
delete headers['server'];
return response;
}Result:
// Before
server: Amazon S3
// After
server: CloudFrontSo you don’t leak what’s behind the CDN. Details that nobody really needs.
Final result
Now we’re there:
- Blog served from S3
- CloudFront as a global CDN
- Clean URLs
- Near-zero cost
- Simple infra that’s easy to explain on a whiteboard
No magic. Nothing mysterious.
Final checklist
- Private bucket
- CloudFront in front
- HTML uploaded
- URLs without
/index.html - One-command deploy
What’s next
In the next post:
- Custom domain
- Route 53
- Google Search Console
The blog is online.
Your mommy will be proud.
Now let’s add a custom domain and show it to Google.
Posts in this series
1️⃣ Part 1: A blog shouldn’t be a SaaS
2️⃣ Part 2: Preparing an Astro blog with good judgment
👉 Part 3: S3 + CloudFront to serve a fast and cheap static blog
4️⃣ Part 4: Custom domain with Route 53 and CloudFront for an Astro SSG blog
5️⃣ Part 5: Automate Astro SSG blog deployment with GitHub Actions and AWS
🐙 GitHub Repo: demo-astro-ssg-s3-cloudfront↗