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:
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:
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:
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:
Associate the function with:
Viewer request
The flow is then:
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: