On this page

Building, uploading to S3 and serving through CloudFront

The build and upload steps, why CloudFront sits in front of S3, and using a CloudFront Function to serve clean URLs without index.html.

Build the website

The build section is intentionally simple:

- name: Install dependencies
  run: npm ci

- name: Build Astro
  run: npm run build

If everything works, Astro produces:

dist/

For example:

dist/
├── index.html
├── about/
│   └── index.html
├── projects/
│   └── index.html
├── blog/
│   └── ...
└── assets/

The CI system does not deploy the source project.

It deploys this generated output.

Deploy the build to S3

The deployment command is:

aws s3 sync ./dist "s3://${AWS_S3_BUCKET}" --delete --only-show-errors

The important part is:

./dist

on the left.

That’s the local build output.

The right side is:

s3://${AWS_S3_BUCKET}

where AWS_S3_BUCKET comes from a repository secret. The workflow binds it to an environment variable rather than writing it into the command, so the value is masked in the log.

Therefore the workflow itself remains generic.

Configure CloudFront

Next, create a CloudFront distribution.

The origin should point to the S3 bucket.

For the S3 REST endpoint, the origin will look similar to:

YOUR_S3_BUCKET.s3.YOUR_AWS_REGION.amazonaws.com

For example:

my-astro-blog-production.s3.eu-north-1.amazonaws.com

The exact endpoint depends on the bucket’s region.

For a private S3 origin, CloudFront should be configured with Origin Access Control (OAC) so that CloudFront can access the bucket without making the bucket public.

Why CloudFront?

CloudFront provides several useful things:

  • Global content delivery
  • HTTPS
  • Caching
  • Custom domains
  • Integration with AWS origins
  • Edge processing through CloudFront Functions

The resulting architecture is:

Deploy path from the user through CloudFront to the S3 origin User CloudFront HTTPS S3
CloudFront caches at the edge and reaches S3 privately, so the bucket needs no public policy of its own.

The user doesn’t need direct access to the S3 bucket.

The clean URL problem

After deploying the website, I encountered an interesting problem.

The homepage worked:

/

but clicking a link such as:

/projects

returned:

<Error>
  <Code>AccessDenied</Code>
  <Message>Access Denied</Message>
</Error>

At first this looked like an S3 permissions problem.

But the actual problem was URL resolution.

Astro had generated:

Clean URL rewritten by a CloudFront function before the S3 object is served Browser CloudFront GET /projects CloudFront function rewrite /projects S3 serve object projects/index.html
A visitor sees a clean URL; the rewrite happens at the edge before the request ever reaches S3.

while the browser requested:

/projects

S3 doesn’t automatically interpret the request in the same way as a traditional web server configured with directory indexes.

The desired transformation was:

How a clean URL resolves to an object in S3 Browser CloudFront GET /projects, function rewrites it S3 projects/index.html
The rewrite happens at the edge, so S3 never sees a request for a URL that does not exist.

Solving the clean URL problem with CloudFront Functions

I created a CloudFront Function.

For example:

astro-url-rewrite

The function code is:

function handler(event) {
    var request = event.request;
    var uri = request.uri;

    // If the request already points to a file, leave it unchanged
    if (uri.includes('.')) {
        return request;
    }

    // Remove trailing slash
    if (uri.endsWith('/')) {
        uri = uri.slice(0, -1);
    }

    // Root
    if (uri === '') {
        request.uri = '/index.html';
    } else {
        request.uri = uri + '/index.html';
    }

    return request;
}

CloudFront Functions can modify viewer requests before they are sent to the origin. AWS documents URL rewriting patterns for this type of static-site routing. AWS CloudFront URL rewrite example

Attach the function to CloudFront

In the CloudFront distribution:

Editing a CloudFront function Behaviors Default behavior Edit Function associations
The function is attached to the behaviour rather than the distribution, so it can be swapped per cache behaviour.

Associate the function with:

Viewer request

The flow is then:

How a clean URL resolves to an object in S3 Browser CloudFront GET /projects, function rewrites it S3 projects/index.html
The rewrite happens at the edge, so S3 never sees a request for a URL that does not exist.

How the URL rewriting works

The function produces these mappings:

/                     → /index.html

/projects             → /projects/index.html

/projects/            → /projects/index.html

/about                → /about/index.html

But files remain unchanged:

/assets/main.css      → /assets/main.css

/images/logo.svg      → /images/logo.svg

/favicon.svg          → /favicon.svg

The reason is this condition:

if (uri.includes('.')) {
    return request;
}

A URL containing a file extension is assumed to already point to a file.

The complete deployment flow

At this point the entire pipeline is:

Deployment path from a local push to a served page Developer GitHub source code git push GitHub Actions checkout · npm ci · npm run build AWS STS OIDC IAM role Amazon S3 dist/ CloudFront URL rewrite · CDN · HTTPS Website
The only hand-off that leaves your machine is the OIDC exchange; everything after it is AWS talking to AWS.