I Migrated My Astro Site from Vercel to AWS. The CDN Routing Was the Weird Part.

Published at:

The migration itself was straightforward. The interesting part came afterward, when the same CloudFront distribution served my root domain from Los Angeles while www was served from Mumbai. The fix turned out to have nothing to do with S3, CloudFront configuration, or Route 53.

It was DNS.

Why move a static Astro site to AWS?

Vercel is very good at making deployment boring:

git push -> Vercel -> Production

That’s useful. It also means you don’t have to think much about the infrastructure underneath.

I wanted the opposite.

I wanted to know what actually happens between:

git push

and:

https://smitmistry.com

For a static Astro site, there isn’t much application infrastructure to manage. The site is already just a collection of files:

dist/
├── _astro/
├── index.html
├── posts/
├── og/
└── ...

So the AWS architecture could stay relatively simple:

GitHub

   │ push

GitHub Actions

   │ OIDC

AWS IAM

   │ temporary credentials

S3

   │ private origin

CloudFront


Route 53

   ├── smitmistry.com
   └── www.smitmistry.com

There is no EC2 instance. No server to patch. No database. No Docker container.

For this particular website, I didn’t need any of them.


Why S3 is the origin instead of a public website bucket

The obvious setup for a static website is to make S3 public and serve files directly from it.

I didn’t want that.

The browser only talks to CloudFront:

Browser


CloudFront


Private S3 bucket

S3 stores the files, but isn’t the public endpoint.

That gives me one place to deal with HTTPS, caching, domains, and edge delivery. S3 is just the origin.

This also means I can change the CDN configuration without changing how the files are stored.


CloudFront handles the public website

CloudFront is the part that actually serves the website to visitors.

I added both:

smitmistry.com
www.smitmistry.com

as alternate domain names on the distribution.

The TLS certificate comes from ACM. One CloudFront-specific detail caught me the first time: the ACM certificate used by CloudFront needs to be in us-east-1, even though my other AWS resources are in ap-south-1.

That initially looks backwards.

It makes more sense once you stop thinking of CloudFront as a Mumbai AWS service. CloudFront is a global service, and the certificate is associated with the global distribution.


Route 53 handles DNS, but Namecheap still owns the domain

I initially kept DNS at Namecheap.

Eventually I moved the DNS zone to Route 53 because I wanted the AWS side of the architecture to be explicit and to use a native Route 53 Alias record for the apex domain.

The distinction is important:

Namecheap

    └── domain registration

Route 53

    └── authoritative DNS

CloudFront

    └── HTTP/CDN

S3

    └── website files

Moving DNS to Route 53 doesn’t mean transferring the domain registration away from Namecheap.

The domain stays registered at Namecheap. The authoritative nameservers point at Route 53.


GitHub Actions deploys to AWS without storing AWS keys

The deployment pipeline was the part I cared about most.

I didn’t want this:

GitHub

   ├── AWS_ACCESS_KEY_ID
   └── AWS_SECRET_ACCESS_KEY

Long-lived credentials in CI are an unnecessary liability.

Instead, I configured GitHub’s OIDC provider in IAM.

The resulting flow is:

GitHub Actions

      │ OIDC token

AWS IAM

      │ AssumeRoleWithWebIdentity

Temporary credentials

      ├── S3
      └── CloudFront

The IAM trust policy is restricted to my repository and branch, rather than trusting every GitHub Actions workflow that can reach the OIDC provider.

The deployment itself is then just:

git push

GitHub Actions

pnpm install

pnpm build

aws s3 sync dist/ s3://...

CloudFront invalidation

The useful part here isn’t that the YAML is complicated. It isn’t.

The useful part is understanding who is allowed to do what, and how that identity is established.


Then the website was inexplicably slow

After the migration was working, I noticed something strange.

The CloudFront distribution hostname was fast:

https://d1z5t0yefv14fd.cloudfront.net

But:

https://smitmistry.com

was much slower.

At one point I was seeing roughly:

CloudFront hostname: ~0.3s
Custom domain:       ~1.6s

That’s enough of a difference that “it’s probably just the Internet” wasn’t a satisfying answer.

So I stopped guessing and measured the request.

curl -4 -s -o /dev/null \
  -w 'DNS: %{time_namelookup}s\nConnect: %{time_connect}s\nTLS: %{time_appconnect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n' \
  https://smitmistry.com

This breaks the request into:

I also checked the CloudFront headers:

curl -sI https://smitmistry.com \
  | grep -Ei 'x-amz-cf-pop|x-cache'

That’s when the problem became interesting.


The same CloudFront distribution was using different POPs

The root domain was hitting:

x-amz-cf-pop: LAX54-P11

That’s Los Angeles.

Meanwhile www was hitting:

x-amz-cf-pop: BOM78-P11

That’s Mumbai.

So the request paths looked roughly like:

smitmistry.com


CloudFront


LAX54-P11


S3

while:

www.smitmistry.com


CloudFront


BOM78-P11


S3

I was sitting in India.

Sending my request to Los Angeles was not exactly what I had in mind when I put a CDN in front of the site.

The first assumption was that something was wrong with the apex DNS configuration.

So I moved the DNS zone from Namecheap to Route 53.

It didn’t fix it.

The root domain was still getting sent to LAX.

That was useful, though. It eliminated one whole category of possible problems.


DNS resolvers turned out to be the variable

I then queried the domain through different recursive resolvers.

With Google’s DNS:

dig @8.8.8.8 smitmistry.com A +short

I got:

13.227.249.61
13.227.249.2
13.227.249.40
13.227.249.42

The same addresses were returned for www.

With Cloudflare DNS:

dig @1.1.1.1 smitmistry.com A +short

I got:

99.84.41.81
99.84.41.76
99.84.41.70
99.84.41.28

while www still returned the 13.227.249.x addresses.

The difference wasn’t my S3 bucket.

It wasn’t my CloudFront cache.

It wasn’t my TLS certificate.

It was the DNS resolution path used to reach CloudFront.

There was another interesting clue in Google’s response:

edns0-client-subnet 49.36.78.0/24

The recursive resolver had client subnet information available when resolving the request.

That helped explain why different recursive DNS resolvers could result in different CloudFront edge selections.


Changing DNS fixed the latency

I had been using Cloudflare’s:

1.1.1.1
1.0.0.1

on my desktop.

I changed the system resolver to Google’s:

8.8.8.8
8.8.4.4

and tested again.

The root domain went from roughly 1.5 to 1.8 seconds down to:

DNS:     0.001s
Connect: 0.012s
TLS:     0.040s
TTFB:    0.119s
Total:   0.120s

And CloudFront was now reporting:

x-amz-cf-pop: BOM78-P11

Both hostnames were reaching the Mumbai edge.

The final measurements were roughly:

smitmistry.com
    CloudFront POP: BOM78-P11
    TTFB:            119 ms
    Total:           120 ms

www.smitmistry.com
    CloudFront POP: BOM78-P11
    TTFB:            192 ms
    Total:           192 ms

The numbers vary from request to request, but the difference from the original 1.5+ second requests was obvious.


What didn’t fix it

A few things I initially suspected turned out not to be the cause.

Moving DNS to Route 53

It was still slow after the migration.

Route 53 was doing its job correctly. It wasn’t the component choosing the final CloudFront edge.

Changing cache headers

The response had:

Cache-Control: public,max-age=0,must-revalidate

That deserves attention for a static Astro site, especially for content-hashed assets.

But it wasn’t the explanation for a request taking more than a second longer than another request to the same distribution.

I would have been treating the symptom rather than finding the cause.

S3

S3 wasn’t the problem either.

The object was already being served through CloudFront, and the ETag matched between requests.


Why I still prefer this setup over a managed frontend platform

I don’t think the lesson here is “AWS is better than Vercel.”

That would be a pretty silly conclusion from one personal website.

Vercel is very good at removing infrastructure work.

That’s exactly why people use it.

The trade-off is control.

With the AWS setup, I now know exactly where the pieces live:

DNS       → Route 53
TLS       → ACM
CDN       → CloudFront
Origin    → private S3
Identity  → IAM + GitHub OIDC
CI/CD     → GitHub Actions
Build     → Astro

If something breaks, I have somewhere to look.

That’s the part I wanted.


What I would use AWS for, and what I wouldn’t

For this website, AWS makes sense because it’s static.

There is no reason to introduce EC2 just to host generated HTML.

For a different application, I might make a completely different choice.

If I had a Next.js application with lots of server-side rendering, background jobs, databases, queues, and other moving parts, I’d have to compare the operational cost against the value of having all that control.

That’s the trade-off.

Managed platforms aren’t expensive simply because they’re charging for a button. You’re paying someone else to operate parts of the infrastructure.

With AWS, I get more control.

I also get more opportunities to break things.

Both are real benefits.


What this migration taught me

The most useful thing I got from this migration wasn’t an AWS architecture diagram.

It was a debugging habit.

When a request is slow, don’t start changing random configuration.

Measure:

DNS

TCP

TLS

TTFB

HTTP response

CDN edge

origin

In my case, the website looked like a CloudFront performance problem.

It wasn’t.

The CloudFront distribution was fine. The S3 origin was fine. Route 53 was fine.

The interesting difference was happening before the request even reached the edge I expected.

That’s the kind of thing managed platforms make easy to ignore.

And, honestly, that’s exactly why I wanted to do this migration in the first place.


The final setup

                         GitHub

                         git push


                    GitHub Actions

                         OIDC


                       AWS IAM

                      temporary creds


                       Astro build


                   ┌─────────────────┐
                   │   Private S3    │
                   │  website files  │
                   └────────┬────────┘

                          origin


                   ┌─────────────────┐
                   │   CloudFront    │
                   │    BOM78-P11    │
                   └────────┬────────┘


                       Route 53
                       /       \
                      /         \
                     ▼           ▼
             smitmistry.com    www

The site is now deployed entirely from GitHub, the S3 origin isn’t public, AWS access from CI uses short-lived credentials, and requests from my network reach the Mumbai CloudFront edge.

More importantly, I now understand the stack instead of just knowing which button to press.