Shortdark Software Development

Setting up an AWS S3 Static Website with CloudFlare

Development16th Feb 2022.Last Updated: 20th Feb 2022.Time to read: 14 mins

AWSS3CloudFlareCloudFrontWAFLambda@EdgeAWS Certificate ManagerStatic WebsiteSecurity HeadersIAMAWS CLITutorial

This is a quick guide to setting up a static website on AWS S3. Not only are we securing the bucket with our bucket policy and bucket permissions, we're also using a CloudFront Function to redirect the CloudFront distribution URL to our domain name. As a further security measure we're using either another CloudFront Function or a Lambda@Edge function to tighten up the security headers before handing over to CloudFlare. We only want secure traffic between CloudFront and CloudFlare, so we also need to use the AWS Certificate Manager too.

Note: This is a complete rewrite of a post from 2020 that was outdated. The old article has been removed as I do not believe it would still work correctly today.

Contents

What We're Aiming For

For this guide, I'm assuming I already have a website called website.com, and I want to set up the subdomain as blog.website.com.

S3 >> CloudFront >> CloudFlare
  • We do not want anyone to be able to access the S3 bucket directly.
  • We do not want anyone to be able to access the CloudFront distribution URL directly.
  • We only want people to view the website through the proper means, i.e. by going to //blog.website.com

With some of this, there are a few different ways you can go about it. Mixing and matching configuration from different methods doesn't always work in AWS.

CloudFlare is Optional

CloudFlare isn't strictly required here. If the domain name is on AWS Route53 it would make more sense not to use CloudFlare at all. I personally like to put domains through CloudFlare as a DNS router, as well as for all the other benefits it brings. There are pros and cons to it though.

One drawback might be that having CloudFront and CloudFlare both caching would be hard to check the changes you've just made actually work. As such, I tend to use CloudFlare for caching and turn off caching on CloudFront.

The alternative to using CloudFlare (especially if using Route53 as a registrar) is to use the default managed caching in CloudFront. If you are doing it this way it would make more sense to use Lambda@Edge for the security headers than a CloudFront Function because you'd be able to cache the headers along with the page content.

AWS Certificate Manager

If I already have the main domain in AWS Certificate Manager, I tend to add it again including all the subdomains I want then delete the old version for neatness. We're going through CloudFlare, so we won't have to add the CNAMEs for the main domain again if it's already set up. When we add CNAMEs to CloudFlare we just have to remember to turn off the proxy (orange cloud) in the CloudFlare console, so the Certificate Manager CNAMEs should all have grey clouds.

S3

  • Set up a new bucket with the name of the subdomain/domain, i.e. blog.website.com.
  • Block public access.
  • ACL disabled (default).
  • Default settings.

We're not going to upload anything at this stage, we'll do that at the end.

CloudFront

  • Select "origin access identities" which should be in the menu on the left and create a new identity with a name related to the domain name.
  • Create a new distribution.
  • Select the S3 bucket from the dropdown.
  • Select Yes use OAI and select the identity you created from the dropdown, also select Yes, update the bucket policy.
  • Alternate domain name (CNAME) is required, in our case it is blog.website.com.
  • Custom SSL certificate is required, and we select the certificate we've already created from the dropdown.
  • Select HTTPS only.
  • Default root object is index.html (or whatever the entrypoint is to your static website).
  • Caching Disabled!
  • Create!

If you have more than one distribution make a note of the last three characters of the distribution ID, so you can find it from the dropdown in Lambda.

This is the basic CloudFront setup. I'd suggest setting it up initially without the Managed Caching even if you'll eventually be using the CloudFront caching.

Deploying a CloudFront distribution takes a while, but we will have to deploy several times. After the initial setup we'll need to re-deploy every time we add a new CloudFront or Lambda@Edge Function. Also, dealing with errors, every time we add a new error behavior we need to redeploy.

Once everything is setup and you've checked everything is working correctly, you can switch from Caching Disabled to Managed Caching.

CloudFlare

Create another CNAME which points the subdomain to the CloudFront distribution URL, copy-and-paste straight from the list of distribution on CloudFront. This one should be a normal orange cloud.

The S3 bucket is currently empty, but if it did have a static website, it should be visible.

At this point, it might be a good idea to switch the domain onto "development mode" in the CloudFlare console. An alternative is to keep clearing the cache after every change (which can take up to 30 seconds). Just remember to switch it off when you're finished.

Deploying

If we are manually uploading files into the AWS S3 console we don't need to do anything with users or permissions in IAM. When it comes to automatically deploying we will need to make sure our IAM users are set up correctly.

IAM

IAM is short for Identity and Access Management. Generally speaking you only want specific users to be able to deploy to the website and each IAM User should have the minimum of privileges. We're going to make user(s) that have no permissions. The IAM User will be granted permissions by being added to group(s) that have different policies.

  • Create a User with no permissions, add a User Group that will eventually do the specific things the user needs to do.
  • Add the IAM User credentials to your ~/.aws/credentials file.
  • If you have multiple IAM Users, you can do something like this...
[default]
aws_access_key_id=<aws_access_key_id>
aws_secret_access_key=<aws_secret_access_key>

[<profile_name>]
aws_access_key_id=<aws_access_key_id>
aws_secret_access_key=<aws_secret_access_key>

Or, you can just use the one IAM User (default) and add it to whichever groups for whichever functionality you need.

  • Create a policy that does exactly what we need, without extra un-needed permissions. In this case, we need to add the specific S3 actions we need to deploy.
  • Attach the policy to the group that our new user is a member of.
  • Now, the only people who should be in this group are people who need to deploy to the website.

This is a good article in the official docs: Writing IAM Policies: How to Grant Access to an Amazon S3 Bucket. An IAM User really does not need many permissions at all to deploy to an S3 bucket.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": ["arn:aws:s3:::bucket_name"]
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:DeleteObject"
      ],
      "Resource": ["arn:aws:s3:::bucket_name/*"]
    }
  ]
}

Uploading files to S3 programmatically

Once we've created a user with the required permissions, we can deploy to S3.

You can look at the scripts part of the package.json, you may have already installed a plugin that deals with uploading. If not you can just upload to S3 via AWS CLI. With VueJS, you might have a scripts' section of the package.json like this...

{
  "name": "my-new-blog",
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "deploy": "aws s3 sync dist/ s3://blog.website.com --profile profile_name",
    "dev": "vue-cli-service build --mode development"
  }
}

With this setup, you can serve locally by running this command...

npm run serve

Then, each time you want to deploy you can lint, build and deploy...

npm run lint
npm run build
npm run deploy

If your IAM User has the correct permissions the deploy should not have any errors. Sometimes the default scripts for deployment use ACL, so you may have to remove the ACL parts of whichever script you are using. The most important thing is to resist the temptation to add all permissions to your policy, as you may forget to remove them afterwards.

After deploying the website should be visible at whichever URL you are using, e.g. //blog.website.com/ .

CloudFront Function Redirect

Create a new CloudFront Function to redirect requests that do not come from our domain name (blog.website.com). From Redirect the viewer to a new URL...

function handler(event) {
    var request = event.request;
    var headers = request.headers;
    var newurl = 'https://blog.website.com/';
  
    if (headers['host'].value != 'blog.website.com') {
        var response = {
            statusCode: 302,
            statusDescription: 'Found',
            headers:
                    { "location": { "value": newurl } }
        }
        return response;
    }
    
    return request;
}

This function then gets added to the association to the viewer request of our CloudFront distribution. If we are viewing the URL properly we see the content as expected, if we are trying to view the CloudFront distribution URL we get redirected to the proper place.

Finally, make sure the redirect works and make sure the site loads properly. You can test the function in the CloudFront Function "Test" tab before deploying it. To check the redirect is working after it is deployed:

  • Load the distribution URL in an incognito browser and see what happens,
  • Use one of various "redirect checkers" online, or
  • Use curl.

Something like this in curl would show you how the redirect was working, using your distribution URL...

curl -i https://gidjgkdhg.cloudfront.net

Or, if you only want to know the URL...

curl -Ls -o /dev/null -w %{url_effective} https://gidjgkdhg.cloudfront.net

Redirect Alternative

Previously, I have set static websites up to block the distribution URL from being displayed outside our domain name (e.g. blog.website.com). One way to do this is by using AWS WAF and only allowing the content to be viewed if the viewer has come from the domain via CloudFlare. Within WAF you say to block (403) unless the user comes from an IP in the CloudFlare IP ranges.

However, with the latest website I am setting up, which happens to be ReactJS, with the current code I'm using, it's not possible to deal with 404s very nicely. The reason being that a file not found coming back from S3, is actually a 403 error. So, if you treat all 403 errors like you would deal with a "404 File not found" in CloudFront you bypass WAF blocking the website because that is also a 403 error. Hence, by making sure the viewer is redirected to the correct domain when someone tries to view the CloudFront domain we can deal with non-existent URLs in a more user-friendly way.

For other setup methods within AWS or other types of static website there may not be a problem with using the WAF and still being able to deal with 404's and block the distribution URL. For example, if I have each page URL as a hash, e.g. //blog.website.com/#page1, non-existent pages would not return a 403 error and can be dealt with by the code.

Security Headers

Security headers allow you to specify how the website should behave. This, makes it more secure for the user because if any malicious code does get into the website, the headers should prevent it from doing any harm.

If you were running a normal webserver and not a static website you could add these headers in Nginx or Apache. You can also add them in CloudFlare. Or, I believe there are some Security Headers already set up in CloudFront as a selectable managed rule from a dropdown. If they do not exist by default in CloudFront, they can definitely be added as a rule. Here we're using Lambda@Edge.

  • At this point everything else should be working correctly, certainly you should be able to view the website normally at //blog.website.com or whatever the address is.
  • Open the site in a browser and open devtools.
  • Go to the "Network" tab, reload, click the first item, and you should see the headers.

Another way to check security headers is to go to securityheaders.com and the more detailed Mozilla Observatory.

Security Headers with a CloudFront Functions

The CloudFront Function to redirect, can work alongside Lambda@Edge, but if you are using a CloudFront Function you may as well use it for both.

To my brain, CloudFront is similar to Lambda@Edge but simpler, quicker and easier to use.

The redirect is associated with the Viewer Request and the Security Headers will be associated with the Viewer Response.

From Add security headers to the response...

function handler(event) {
    var response = event.response;
    var headers = response.headers;

    // Set HTTP security headers
    // Since JavaScript doesn't allow for hyphens in variable names, we use the dict["key"] notation 
    headers['strict-transport-security'] = { value: 'max-age=63072000; includeSubdomains; preload'};
    headers['content-security-policy'] = { value: "default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'"};
    headers['x-content-type-options'] = { value: 'nosniff'};
    headers['x-frame-options'] = {value: 'DENY'};
    headers['x-xss-protection'] = {value: '1; mode=block'};

    // Return the response to viewers 
    return response;
}

After the function has been saved, published and associated with the Viewer Response the headers should be added to the website. Check that the Function is added to your distribution by going back to the CloudFront distribution and clicking edit on the "Behaviors" tab. The function should be added at the bottom of the page next to "Viewer Response".

Now, check the headers are added correctly by inspecting the website in a browser and viewing the headers, then check the site again at securityheaders.com. If the javascript in the CloudFont Function produces an error, all the headers will vanish so checking afterwards is very important. CloudFront Functions also have a testing tab which can show you if your changes are going to produce an error before you deploy.

Once you're happy with the redirection and security headers we're done! You can switch off development mode in CloudFlare.

Security Headers with Lambda@Edge

Alternatively, we can also use Lambda@Edge to add Security Headers to our static website.

You will need the CloudFront distribution ID to deploy so make sure you have CloudFront open in another tab.

This AWS guide is pretty good: Adding HTTP Security Headers Using Lambda@Edge and Amazon CloudFront This is fiddly, but after running through the process several times you get used to it.

  • Create a new Lambda Function from blueprint "cloudfront-modify-response-header".
  • Configure Trigger: select cloudfront distribution from dropdown and choose "origin response" or "viewer response".
  • Confirm deploy to Lambda@Edge.
  • Deploy!
  • Publish, then make sure your Lambda@Edge function is associated with your CloudFront distribution.

Modifying the template gives you something like this for some common security headers. Research what each of the headers do, Content Security Policy (CSP) in particular can need a lot of tweaking for each individual website...

'use strict';
exports.handler = (event, context, callback) => {

    //Get contents of response
    const response = event.Records[0].cf.response;
    const headers = response.headers;

    //Set new headers
    headers['content-security-policy'] = [{key: 'Content-Security-Policy', value: "default-src 'self'; connect-src 'self'; img-src 'self'; script-src 'self'; style-src 'self'; style-src-elem 'self'; object-src 'none'; font-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none';"}]; 
    headers['x-content-type-options'] = [{key: 'X-Content-Type-Options', value: 'nosniff'}];
    headers['x-frame-options'] = [{key: 'X-Frame-Options', value: 'DENY'}];
    headers['x-xss-protection'] = [{key: 'X-XSS-Protection', value: '1; mode=block'}];
    headers['referrer-policy'] = [{key: 'Referrer-Policy', value: 'same-origin'}];
    headers['permissions-policy'] = [{key: 'Permissions-Policy', value: 'geolocation=(), microphone=()'}];
    headers['strict-transport-security'] = [{key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains'}];

    //Return modified response
    callback(null, response);
};

Every time you make a change to the code the CloudFront distribution will need to re-deploy, this can take a while. To save time, test the code first before you publish in the "Test" part of the AWS Lambda console.

The actual deploying I found extremely fiddly the first time I tried it. Once Lambda@Edge is saved and deployed go back to CloudFront, the distribution will probably be re-deploying. Check that the Lambda@Edge function has been added in the correct place by going back to the CloudFront distribution and clicking edit on the "behaviors" tab. Whichever behavior you specified ("origin response" or "viewer response") should have the Lambda@Edge function next to it. Once the distribution is re-deployed check the website still works and re-check the "Response Headers" and/or securityheaders.com to see if your changes have been made.

To check the Lambda@Edge function is added to CloudFront (which it should be), go to the distribution, the "behaviors" tab and edit.

Once you're happy with the Security Headers go back to CloudFlare and switch development mode off.

Summary

This is just one method to set this up this functionality. There can be two or three different ways to set up each part.

For example, in this method we are not telling S3 that we want to serve the bucket as a website, we're leaving everything to CloudFront. As a result we cannot view the S3 bucket by default. If we did want to view the S3 bucket we can enable that in the properties tab of the S3 bucket, then we would also need to either make the bucket public or modify the permissions of the bucket. It makes sense to want to view the S3 bucket before setting up CloudFront, but it is not needed, and it could cause confusion.

I have set up various static websites with Vue, Gatsby and React. How the sites are set up on AWS may depend on the javascript framework that is being used and how it is being implemented.


Previous: PHP 8 and Nginx on Ubuntu 20.04 LTS TutorialNext: Using Amazon Certificate Manager (ACM) with EC2 and ELB/ALB