back to shanebodimer.com/ramblings

Static hosting with AWS and CloudFlare

NOTE: This isn't really meant to be a guide or a tutorial. Just an account of everything I did to make this work and everything I learned along the way. I'm sure I'll want to set up more static sites in the future so I'm writing down my process now before I forget

I used to host all my static websites with Dreamhost.com. It is so easy.

Dreamhost was great but it was expensive. I think I was paying around $120 a year for a hosting plan. I can't find that plan on their pricing page anymore, looks like a single site now is $7 a month. I still care about two static sites I own (this one and another one). If I wanted to use Dreamhost, that would be $168 a year. Not for me. I wanted something cheaper.

I decided to switch to AWS for hosting and use CloudFlare for protection. Now my costs are 50 cents a month per site totaling just $12 a year.

I am sure there are countless other static hosting sites that could do it cheaper or easier than AWS + CloudFlare. Like CloudFlare Pages (https://pages.cloudflare.com/) or Netlify (https://www.netlify.com/) or GitHub Pages (https://pages.github.com/). Whatever. I don't care. I like learning how to use AWS and like that I can start doing more complicated things in the future if I want. I also have never used CloudFlare before so this was a good excuse. I also use AWS for work so this is another good excuse to learn more.

Here is everything I did to set up shanebodimer.com resulting in this architecture:

Requirements

Moving off Dreamhost, I lost a lot of features. I wanted to keep these core things:

My domain was already registered in Route 53 on AWS (rip Google Domains) so I didn't have to worry about that.

A simple upload process

This is straightforward enough. AWS S3 is the obvious place to upload files. I created a bucket titled shanebodimer.com.

Dragging and dropping files into the bucket is easy but I wanted something even easier. Doing 2FA to get into AWS everytime I want to upload is annoying, it needed to be easier :)

To accomplish this, I used GitHub Actions. I copied this action template from here: https://medium.com/@olayinkasamuel44/how-to-deploy-a-static-website-to-s3-bucket-using-github-actions-ci-script-fa1acc932fbd

name: deploy static website to AWS-S3
          
          on:
            push:
          jobs:
            deploy:
              runs-on: ubuntu-latest
          
              steps:
                - name: Checkout repository
                  uses: actions/checkout@v4
          
                - name: Set up AWS CLI
                  uses: aws-actions/configure-aws-credentials@v4
                  with:
                    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
                    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
                    aws-region: us-east-1
          
                - name: Deploy to AWS S3
                  run: |
                    aws s3 sync . s3://shanebodimer.com --delete
          

Pretty simple. I push to my repo, this action uploads it to S3 and overwrites the existing files. As soon as I finish writing this, I am going to git push, and it will be live. Too easy.

I don't think I ran into a single error setting this up.

A static website

Ok now that I have the files on S3, I need to serve them!

My first tutorial was the classic Static Website with S3 (https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteHosting.html) but this only results in an ugly S3 URL to access the sites and the bucket had to be public. This is bad and I'll explain later.

My second tutorial was Static Website with Custom Domain (https://docs.aws.amazon.com/AmazonS3/latest/userguide/website-hosting-custom-domain-walkthrough.html) but that required me to make two buckets (why??), there was no SSL cert, and it still required the buckets to be set to public.

Finally, I found a tutorial to serve a static site with SSL using CloudFront: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/getting-started-secure-static-website-cloudformation-template.htm

It was using CloudFormation to deploy which I wanted to avoid for these reasons:

I created a CloudFront distribution following the basic settings:

For the Origin (where CloudFront gets the files to serve), I created a new S3 Origin and picked my shanebodimer.com S3 bucket. For origin access, I created a new Origin access control called shanebodimer.

OAC is so important because it let's only my CloudFront distribution talk to my S3. If my S3 bucket was set to public like the other tutorials suggest, anyone can directly access the S3 bucket endpoint and start downloading files. This could skyrocket my AWS bill. OAC forces any requests to my files to go through CloudFront.

OAC created this policy for me with s3:GetObject. I added the s3:ListBucket to help with 404s. When you request a route, sometimes it was 404ing. ListBucket fixes that I think. Now CloudFront is able to actually see what is in a folder rather than trying to serve the folder as an object. At least I think that's how it's working. I think this is a result of this behavior: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DefaultRootObject.html#DefaultRootObjectNotSet. I could probably remove it now that I have additional routing rules but whatever.

{
              "Version": "2008-10-17",
              "Id": "PolicyForCloudFrontPrivateContent",
              "Statement": [
                  {
                      "Sid": "ServeFiles",
                      "Effect": "Allow",
                      "Principal": {
                          "Service": "cloudfront.amazonaws.com"
                      },
                      "Action": "s3:GetObject",
                      "Resource": "arn:aws:s3:::shanebodimer.com/*",
                      "Condition": {
                          "StringEquals": {
                              "AWS:SourceArn": "arn:aws:cloudfront::{my aws account number}:distribution/{my cloudfront distribution}"
                          }
                      }
                  },
                  {
                      "Sid": "ListBucketToKnow404",
                      "Effect": "Allow",
                      "Principal": {
                          "Service": "cloudfront.amazonaws.com"
                      },
                      "Action": "s3:ListBucket",
                      "Resource": "arn:aws:s3:::shanebodimer.com",
                      "Condition": {
                          "StringEquals": {
                              "AWS:SourceArn": "arn:aws:cloudfront::{my aws account number}:distribution/{my cloudfront distribution}"
                          }
                      }
                  }
              ]
          }
          

Cool. Now my CloudFront is the only thing that can access my S3 bucket and I have a cert.

I don't remember what I did to connect my domain name. I think AWS just walked me through selecting the domain for CloudFront and it automatically added the A record to point to CloudFront.

Protecting (with CloudFlare)

CloudFront is variable pricing meaning an increase in traffic is an increase in costs. If I were to run a bad script, or someone else runs a script, my CloudFront distribution could get hammered with millions and millions of requests and I could get charged A LOT. I don't want this. I need some sort of protection to filter out spam traffic.

CloudFront offers protection called WAF (Web Application Firewall): https://docs.aws.amazon.com/waf/latest/developerguide/cloudfront-features.html. It does exactly what I want:

Keep your application secure from the most common web threats and security vulnerabilities using AWS WAF. Blocked requests are stopped before they reach your web servers.

BUT it costs like $14 a month for 10,000,000 requests and there are additional monthly costs for having it enabled ($5 a month to have, $1 a month per rule, etc.)

Also I think Shield is better suited for DDoS protection but that's like $3,000 a month? and I have to sign up for a year? https://aws.amazon.com/shield/

CloudFlare can do all this for free!

I made an account with CloudFlare, added my domain, and changed the NameServers on AWS to point to CloudFlare. Within like 5 minutes everything showed connected.

Now, all requests to my site go through CloudFlare first which has security enabled. I can pick any of these levels that will automatically detect and block bad requests: https://developers.cloudflare.com/waf/tools/security-level/#security-levels

I could also talk about how great everything is cached around the world via CloudFlare and CloudFront but I don't care about that. I get like 3 hits a month on my website and they are probably bots lol. I don't care to talk about caching.

Routing rules

I want ALL of these routes to go to https://shanebodimer.com

I want any 404 page to show a 404 page

I want pages not to have index.html (for example, this document is an index.html but you don't see it in the URL)

www to non-www

CloudFlare can handle the www to non-www redirect with rules, they even have a pre-made template for it: https://developers.cloudflare.com/rules/. It is free.

http to https

Cloudflare can also handle this with rules. I get 10 free rules for each domain which is plenty.

404

CloudFront handles this with "Error pages". I can just say on any 404, return the 404.html page. This is free.

index.html

CloudFront handles this with "Functions". What it does is checks if the url doesn't have an index.html at the end, and then tells CloudFront to serve the index.html. I stole this code from Stack Overflow: https://stackoverflow.com/a/69157535

function handler(event) {
              var request = event.request;
              var uri = request.uri;
          
              // Check whether the URI is missing a file name.
              if (uri.endsWith('/')) {
                  request.uri += 'index.html';
              }
              // Check whether the URI is missing a file extension.
              else if (!uri.includes('.')) {
                  request.uri += '/index.html';
              }
          
              return request;
          }
          

I thought the "default root object" setting in CloudFormation would handle this but apparently not. I need this function for my subpages to work. Oh well. Pricing is cheap enough that I'm not worried:

Invocation pricing is $0.10 per 1 million invocations ($0.0000001 per invocation).

It works!!!!!!

That is all. Everything I did to safely host my static sites for a cheap as possible. I'm sure there are better ways but this was fun.

Next time I get bored on a weekend I'll turn this into a CloudFormation template so spinning up sites in the future will be faster.

Bonus: Canaries

At work we talk about canaries a lot. A script or service somewhere that occasionally checks on a service to let us know if it is operating correctly. This is good for services that don't have the best monitoring.

Since I don't have servers or lambda to monitor when something is "broken", I have to rely on canaries to tell me if my sites are down.

I could do this with AWS or other cool websites like https://pingping.io/ but I've been really into Home Assistant lately and wanted an excuse to build on my home network.

I used the Node-RED Home Assistant addon and created a new page to run canaries. This is what it looks like:

Basically once a week, it will ping a handful of my URLs and look for certain text in the response. If that text is missing, my page is not serving correctly and I get a notification on my phone to investigate.

This is how I structured my tests:

const validateString = "Nova"
          const string404 = "404 bro :("
          const urls = [
              {
                  url: 'shanebodimer.com',
                  find: validateString
              },
              {
                  url: 'https://shanebodimer.com',
                  find: validateString
              },
              {
                  url: 'http://shanebodimer.com',
                  find: validateString
              },
              {
                  url: 'www.shanebodimer.com',
                  find: validateString
              },
              {
                  url: 'https://www.shanebodimer.com',
                  find: validateString
              },
              {
                  url: 'http://www.shanebodimer.com',
                  find: validateString
              },
              {
                  url: 'http://www.shanebodimer.com/should404',
                  find: string404
              }
          ]
          msg.urls = urls
          return msg;
          

Bonus: Cost Alerts

The whole reason I did this was to save money and make sure I don't get charged too crazy. To further ease my mind, I set up billing alerts to send me an email whenever my estimated AWS monthly costs are above $5. This is what the config looks like. I just copied from AWS documentation: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/monitor_estimated_charges_with_cloudwatch.html#creating_billing_alarm_with_wizard



back to shanebodimer.com/ramblings