Back to articles
9 min readastro-ssg-aws-s3-cloudfront

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.

AWS
Cloudfront
S3
Astro
Static Site Generation SSG
Web Performance Optimization WPO

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:

PieceWhat it does
AstroGenerates HTML at build time
S3Stores static files
CloudFrontCDN + reverse proxy
(Route 53)Covered in the next post

With this setup, you can serve millions of requests without touching anything else.

Simple but deadly
Simple but deadly

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: 200

      Sometimes 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:

JSON
{ "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:

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

  1. aws s3 sync

    • Uploads files from dist/
    • Removes what no longer exists
    • Sets aggressive caching (great for assets)
  2. 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.html files.

Run it

BASH
$ npm run build $ BLOG_BUCKET_S3=YOUR_BUCKET_NAME BLOG_DISTRIBUTION_ID=YOUR_DISTRIBUTION_ID npm run deploy

After 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:

JS
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:

JS
function handler(event) { var response = event.response; var headers = response.headers; delete headers['server']; return response; }

Result:

HTTP
// Before server: Amazon S3 // After server: CloudFront

So 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

Let's talk?

Looking for someone who understands product as much as code?

Start a conversation

© 2026 Rodrigo de Miguel. All rights reserved.