HTTPS Static Site Hosting in S3

In my last post I mentioned that I wanted to move my site to HTTPS. I initially intended to write about my experience doing so, but instead, I feel like a more general “HOW TO” style would be more useful for people.

This ended up being a fairly long post, if you just want a CloudFormation template without understanding what’s in it, you can find it here.

Why HTTPS for a static site?

If you’re reading this, then I’m assuming that you’ve already made the decision to enable HTTPS on your site. But in case you haven’t, the EFF has a fantastic resource on why it’s important, or you can also check out Why HTTPS matters from Google’s, Web Fundamentals.

For me, the reason I want to make my static site HTTPS is because I believe that the web should be encrypted by default. And I want to contribute to that goal instead of working against it.

What we’ll be doing

To host a static site in S3 with HTTPS, we’ll need a few things. We’ll need:

  • A static site that we want to deploy
  • An AWS account
  • A registered domain name and access to the email address in the WHOIS record for the domain name.

We’ll be setting up a way to run our provisioning and deployment scripts (we’ll try to automate as much of this as possible). I’m also going to set up continuous deployment with CircleCI, because it makes it easier. I’m switching from Github pages, so it will also make my publishing pipeline more consistent.

All up, it should cost next to nothing, perhaps a dollar a month depending on your site traffic. This includes a free TLS certificate, and CloudFront CDN. It’s a little bit more expensive than free, but not by much.

Step 1 - Provisioning All The Things!

We’re going to be using CloudFormation to provision everything we’ll need for our HTTPS static site. This includes:

  • The S3 bucket
  • CloudFront CDN
  • The TLS certificate
  • Route53 DNS routes for our site

We could set everything up manually in the AWS console, but it’s far more repeatable to do it with CloudFormation, which will be useful for the next site we want to provision.

The CloudFormation template has the following parts:

Parameters

CloudFormation parameters are a good way to reduce duplication in our template. We’ll keep ours pretty simple:

Parameters:
  RootDomainName:
    Type: String
    Description: The root domain name for our site, e.g. `example.com`

Resources

AWS::S3::Bucket and AWS::S3:BucketPolicy

We’ll need to deploy our static site somewhere, that’s what this bucket will be. Typically, S3 buckets for static sites need to have the same name as the site, but in this case we’re going to be putting a CloudFront CDN in front of it, so it’s more of a formality. It will make it easier to identify your bucket in the future though. Our BucketPolicy is what allows all of our files to be accessed publicly. Without it, files will be private by default, and requests to our pages will return a 403 response. The CloudFormation for these resources looks like:

  SiteS3Bucket:
    Type: "AWS::S3::Bucket"
    Properties:
      AccessControl: PublicRead
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: error.html
      BucketName: !Ref RootDomainName

  SiteBucketPolicy:
    Type: "AWS::S3::BucketPolicy"
    Properties:
      Bucket: !Ref SiteS3Bucket
      PolicyDocument:
        Statement:
        - Sid: ReadAccess
          Action: s3:GetObject
          Effect: Allow
          Resource: !Join
            - ""
            -
              - "arn:aws:s3:::"
              - !Ref SiteS3Bucket
              - "/*"
          Principal: "*"
AWS::CertificateManager::Certificate

This is our TLS/SSL certificate. AWS provides free certificates with their Certificate Manager service. The only thing you’ll need to do is verify that you own the domain. In order to do this, they’ll send an email to the addresses listed on your WHOIS record, along with some other “common system administration addresses”. We can request one of these certificates with:

  TLSCertificate:
    Type: "AWS::CertificateManager::Certificate"
    Properties:
      DomainName: !Ref RootDomainName
      SubjectAlternativeNames:
        - !Join
          - "."
          -
            - "www"
            - !Ref RootDomainName

What we’re requesting here is a certificate for our RootDomainName and the www subdomain. When we run this, AWS will create a TLS/SSL certificate for our domain with the “Pending validation” status. We should also receive our domain ownership verification email(s), and CloudFormation will wait for us to verify domain ownership before continuing. Just open the email for the root domain and follow the instructions.

AWS::CloudFront::Distribution

CloudFront CDN does a few things. It lets us use our TLS certificate with our website, and also acts as a CDN, caching our pages and serving them to our users around the world. The CloudFront part of the template is probably the most complicated:

  SiteCDN:
    Type: "AWS::CloudFront::Distribution"
    Properties:
      DistributionConfig:
        Enabled: true
        Aliases:
          - !Ref RootDomainName
        Origins:
          - Id: S3Bucket
            DomainName: !GetAtt
              - SiteS3Bucket
              - DomainName
            S3OriginConfig: {}
        HttpVersion: "http2"
        ViewerCertificate:
          AcmCertificateArn: !Ref TLSCertificate
          SslSupportMethod: "sni-only"
        DefaultRootObject: "index.html"
        DefaultCacheBehavior:
          TargetOriginId: S3Bucket
          ViewerProtocolPolicy: "redirect-to-https"
          MaxTTL: 86400
          ForwardedValues:
            QueryString: false
        CustomErrorResponses:
          - ErrorCachingMinTTL: 0
            ErrorCode: 400
            ResponseCode: 400
            ResponsePagePath: /error.html
          - ErrorCachingMinTTL: 0
            ErrorCode: 403
            ResponseCode: 403
            ResponsePagePath: /error.html
          - ErrorCachingMinTTL: 0
            ErrorCode: 404
            ResponseCode: 404
            ResponsePagePath: /error.html
          - ErrorCachingMinTTL: 0
            ErrorCode: 500
            ResponseCode: 500
            ResponsePagePath: /error.html
          - ErrorCachingMinTTL: 0
            ErrorCode: 503
            ResponseCode: 503
            ResponsePagePath: /error.html

This template will provision a CloudFront distribution that essentially proxies our S3 bucket. A request to /path/to/file.html will serve the same file from the S3 bucket. We’re also telling CloudFront to use our newly provisioned TLS Certificate when serving HTTPS requests, and redirecting any HTTP requests to HTTPS.

We need to explicitly tell CloudFront to respond to a request made to / with the /index.html file, and we also need to tell CloudFront how to respond to HTTP error codes coming from the bucket. This has to be done for each different 4xx or 5xx HTTP code we can get, but fortunately the list of response codes that S3 will respond with is relatively small. You can use a different error page for each code, or be lazy like me and use the same one for everything.

CloudFront takes a while to deploy out to all of the CDN edge nodes, so the CloudFormation provisioning will take a while (it took around 20 minutes for me) when you include this part, but just be patient and it will eventually complete.

AWS::Route53::HostedZone and AWS::Route53::RecordSetGroup

At this point, it’s certainly possible to just stop and manually configure our DNS with a CNAME record pointing to our CloudFront distribution. However, Route53 lets us provision this alongside the rest of our setup, which means we’ll have less manual configuration. We’ll also set up DNS Records for both our root domain, and the www subdomain to direct them to our CloudFront distribution.

  SiteHostedZone:
    Type: "AWS::Route53::HostedZone"
    Properties:
      Name: !Ref RootDomainName

  SiteDNSRecord:
    Type: "AWS::Route53::RecordSetGroup"
    Properties:
      HostedZoneId: !Ref SiteHostedZone
      RecordSets:
        - Name: !Ref RootDomainName
          Type: A
          AliasTarget:
            DNSName: !GetAtt
              - SiteCDN
              - DomainName
            HostedZoneId: Z2FDTNDATAQYW2
        - Name: !Join
            - "."
            -
              - "www"
              - !Ref RootDomainName
          Type: CNAME
          TTL: 600
          ResourceRecords:
            - !Ref RootDomainName

After running this, we end up with a HostedZone for our domain, along with an A record which points to our CloudFront distribution (using Route53’s AliasTarget). We’ll also have a CNAME record directing the www subdomain to our RootDomainName, with a TTL of 10 minutes.

Note: The HostedZoneId under our AliasTarget. Z2FDTNDATAQYW2 is CloudFront’s HostedZoneId, and should be the same for any AliasTarget pointing at a CloudFront distribution.

Outputs

CloudFormation lets us specify outputs which we can retrieve with the aws-cli, and easily to find later in the AWS Console. We can use this to give us the URL to our newly provisioned site, as well as telling us the NameServers we’ll need for our Domain Registrar. We’ll also output our Cloudfront Distribution ID which we’ll use later during deployment.

Outputs:
  WebsiteURL:
    Description: URL for the website
    Value: !Join
      - ""
      -
        - "https://"
        - !Ref RootDomainName

  NameServers:
    Description: DNS NameServers for the site
    Value: !Join
      - ", "
      - !GetAtt
        - SiteHostedZone
        - NameServers

  CloudFrontDistributionId:
    Description: The CloudFront Distribution Id to be used for creating invalidations during deployment
    Value: !Ref SiteCDN

Putting it all together

What we end up with is a single CloudFormation template with our Parameters, Outputs, and the above Resources.

We can then provision it by uploading it in the AWS Console, or by using the aws-cli

Instead of putting it all here again, I’ve committed it along with a simple provisioning script over on my Github

Step 2 - Deploying

Deploying our site is a relatively simple task. We simply build our site, then sync the compiled files to S3. I’m going to explain how I’ve done it for this site (given how it’s set up), but the idea should be the same for any static site.

So that we know exactly where our built files are going to end up, it’s a good idea to specify this when we build (if possible). In Jekyll we can pass the destination directory as a build option. I’m going to use an environment variable so that we can re-use it again when we sync to S3. My personal preference for this directory name is public, but you can use whatever you want.

Also, because we’re using CloudFront to cache our pages, we can create an invalidation as part of our deploy. Otherwise, we’ll have to wait a few minutes for any changes to appear on our site.

BUILD_DIR="public"
SITE_URL="example.com"
CLOUDFRONT_DISTRIBUTION_ID=<this-comes-from-the-stack-output>

echo "--- Build Site"
bundle exec jekyll build --destination $BUILD_DIR

echo "--- Sync ${BUILD_DIR} to S3"
aws s3 sync $BUILD_DIR "s3://${SITE_URL}" --delete

echo "--- Invalidating CloudFront cache"
aws cloudfront create-invalidation --distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" --paths "/*"

Step 3 - Use Route53 NameServers

That’s it! You should now be able to set your Domain to use the Route53 NameServers that we output from our CloudFormation stack. How you do this depends on your registrar, but you should be able to find instructions relatively easily. This can take a bit of time to propagate through, but once it does you should have an HTTPS static site deployed to AWS for less than the price of… whatever you can get for a dollar these days, a postage stamp maybe. Either way, it isn’t much.

Setting Goals

“Would you tell me, please, which way I ought to go from here?”
“That depends a good deal on where you want to get to,” said the Cat.
“I don’t much care where–” said Alice.
“Then it doesn’t matter which way you go,” said the Cat.

— Lewis Carroll, Alice’s Adventures in Wonderland