How-To: Setup Gatsby on AWS using GitHub Actions [2/3]: Setup CloudFront Function for index rewrites (with optional basic authentication)
3rd May 2024 - (revised version of a post originally published 13th May 2021)
This is Part 2 of a How-To guide to help you host your Gatsby site on AWS (with Continuous Deployment provided by GitHub Actions). See Part 1 for more information.
NOTE: This information should be useful for anyone looking to add Apache style index.html handling to a CloudFront distribution (regardless of whether you're using Gatsby or not)
Out of the box CloudFront has very basic handling of index.html pages. We need to add the following behaviour otherwise some Gatsby generated links won't work:
- When a user requests a page at /my-blog-post (a directory without a trailing /) we need CloudFront to respond with a 301 response to redirect the user's browser to /my-blog-post/ (the same directory with a trailing slash /)
- When a user requests a page at /my-blog-post/ (a directory with a trailing /) we need to serve them the index.html page within that directory: /my-blog-post/index.html.
CloudFront Functions to the rescue
CloudFront Functions allows us to add some logic to CloudFront to modify how CloudFront responds to user requests. We'll a add a small block of code to implement the missing functionality mentioned above.
Note that we could alternatively use Lambda@Edge to set this up - using CloudFront Functions is the new best practice approach (and more cost effective) but if you'd rather use Lambda@Edge please see this older guide
Creating the CloudFront function
- In the AWS console go to the CloudFront section
- In the left hand panel, click
Functions
- Click
Create function
- For name enter
gatsby-index
- For runtime choose
cloudfront-js-2.0
- Click
Create function
- Cut and paste the following code into the Function code window (if you also want to add basic Authentication, use the version from the Appendix):
import querystringLib from 'querystring'; async function handler(event) { const request = event.request; if (looksLikeADirectory(request.uri) && !request.uri.endsWith('/')) { let redirect = request.uri + '/'; const querystring = stringifyRequestQuerystring(request.querystring); if (querystring) { redirect += '?' + querystring; } return makeRedirect301Response(redirect); } if (request.uri.endsWith('/')) { request.uri += 'index.html'; } return request; } function looksLikeADirectory(path) { const filename = path.split('/').pop(); return !filename.includes('.'); } function makeRedirect301Response(redirect) { return { statusCode: 301, statusDescription: 'Moved Permanently', headers: { location: { value: redirect }, }, }; } function stringifyRequestQuerystring(source) { const dest = {}; for (const key in source) { if (source[key].multiValue) { dest[key] = source[key].multiValue.map(mv => mv.value); } else { dest[key] = source[key].value; } } return querystringLib.stringify(dest, "&", "=", { encodeURIComponent: value => value }); }
- Click
Save changes
(orange button top-right)
- Click
- Click the
Publish
tab- Click
Publish function
- Click
- In the
Associated distributions
section- Click
Add association
and a popup should appear - Distribution: On the list which appears click the ID for your CloudFront distribution (see Part 1 if you need to determine the ID of your CloudFront distribution)
- Event type: Leave as
Viewer Request
- Cache Behavior: Select
Default (*)
- Click
Add association
- Click
If you're interested to see how the CloudFront Function was linked to the CloudFront distribution you can do the following:
- In the AWS console go to the CloudFront section
- Select the appropriate Distribution in the list
- Click the
Behaviours
tab - Tick the single checkbox in the behaviours table and then click
Edit
- Scroll down and you should see the link to the CloudFront Function
So how does this work?
The code above will be run each time CloudFront tries to fetch a file from the S3 bucket (the Origin of the CloudFront distribution). The code checks the requested url and it either responds with a 301 redirect or it tells CloudFront to fetch the appropriate index.html file from the S3 bucket.
And how much will this cost?
There is a free tier for CloudFront Functions and once that has expired pricing is low.
OK, CloudFront function created. What's next?
Next we need to setup the Continuous Deployment environment using GitHub Actions.
Let's move on to Part 3: Continuous Deployment with GitHub Actions.
Appendix - Adding Basic Authentication
If you want to password protect your whole site using Basic Authentication you can use this code instead:
import querystringLib from 'querystring';
const authorizedUsers = [
'user:password',
//... add more entries here as necessary
// all entries should be of the form '<username>:<password>'
];
async function handler(event) {
const request = event.request;
if (!isRequestAuthorized(request.headers)) {
return makeNotAuthorizedResponse();
}
if (looksLikeADirectory(request.uri) && !request.uri.endsWith('/')) {
let redirect = request.uri + '/';
const querystring = stringifyRequestQuerystring(request.querystring);
if (querystring) {
redirect += '?' + querystring;
}
return makeRedirect301Response(redirect);
}
if (request.uri.endsWith('/')) {
request.uri += 'index.html';
}
return request;
}
function isRequestAuthorized(requestHeaders) {
if (!requestHeaders.authorization) return false;
if (!requestHeaders.authorization.value.startsWith('Basic ')) return false;
const authValue = requestHeaders.authorization.value.substring(6);
const credentials = Buffer.from(authValue, 'base64').toString();
return authorizedUsers.includes(credentials);
}
function makeNotAuthorizedResponse() {
return {
statusCode: 401,
statusDescription: 'Unauthorized',
headers: {
'www-authenticate': { value: 'Basic' },
},
};
}
function looksLikeADirectory(path) {
const filename = path.split('/').pop();
return !filename.includes('.');
}
function makeRedirect301Response(redirect) {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: redirect },
},
};
}
function stringifyRequestQuerystring(source) {
const dest = {};
for (const key in source) {
if (source[key].multiValue) {
dest[key] = source[key].multiValue.map(mv => mv.value);
} else {
dest[key] = source[key].value;
}
}
return querystringLib.stringify(dest, "&", "=", {
encodeURIComponent: value => value
});
}
Don't forget to change the entries in authorizedUsers
for your own username and password. If you need to change the entries later on don't forget to Save
and Publish
again.
All done? Let's move on to Part 3: Continuous Deployment with GitHub Actions.