Static Websites in AWS/CloudFormation with HTTPS

I’ve been setting up a separate website recently that needs only simple static content, and I thought I’d have an experiment with getting it HTTPS-enabled on AWS.

I had a couple of requirements:

  • The site must be entirely HTTPS – any requests to a http://... address should respond with a 301 Moved Permanently to the corresponding https://... version.
  • The site must be accessible from both the base subdomain (example.com), and from the www subdomain (www.example.com). To prevent oddities in search results, I want any requests to the base domain to redirect to the www subdomain.

Basically, I want this behaviour:

Request Response
http://example.com 301 https://www.example.com
http://www.example.com 301 https://www.example.com
https://example.com 301 https://www.example.com
https://www.example.com < the static site content >

I don’t much mind if there is more than one redirect to the final site.

Finally, I want this to be managed via CloudFormation.

AWS Functionality

I’m going to make use of the following AWS functionality:

  • S3 can be used to host a static website, either directly, or as a source for CloudFront.
  • S3 can also be used to serve up redirects.
  • CloudFront can be used for SSL-termination to provide HTTPS endpoints.
  • CloudFront can also be set up to redirect from http to https.
  • ACM (Certificate Manager) can be used to provide an X.509 certificate suitable for HTTPS.

CloudFormation Scripts

It took me a little while to realise that from AWS’s perspective, what I actually need to create are two distinct websites:

  • One, on www.example.com, will serve the static content.
  • A second one, on example.com, will simply serve a redirect.

Because of this, much of the CloudFormation script below is effectively duplicated.

I’m going to present the CloudFormation in this blog post as “literate code”, but if you prefer to see it all at once, I’ll link to a gist at the end of this article.

The overall structure looks like this:

AWSTemplateFormatVersion: 2010-09-09
Parameters:
  RootDomainName:
    Type: String
Mappings:
  RegionMap:
    <region-map>

Resources:

  RootCertificate:
    <root-certificate>

  SubdomainCertificate:
    <subdomain-certificate>

  PublicWebsiteRootBucket:
    <root-bucket>

  PublicWebsiteWwwBucket:
    <www-bucket>

  PublicRootBucketPolicy:
    <root-bucket-policy>

  PublicWwwBucketPolicy:
    <www-bucket-policy>

  PublicWebsiteRootCloudfront:
    <root-cloudfront>

  PublicWebsiteWwwCloudfront:
    <www-cloudfront>

  HostedZone:
    <hosted-zone>

  DNS:
    <dns-setup>

This script takes a single input parameter, RootDomainName, which is the root domain name, for example example.com.

First up is the <region-map>. This is just a lookup table of AWS’s own endpoints and hosted zones for S3. I’m probably missing a few regions so if you’re hosting from a newer region you’ll need to update this table. (AWS documentation reference).

    us-east-1:
      S3HostedZoneID: Z3AQBSTGFYJSTF
      S3WebsiteEndpoint: s3-website-us-east-1.amazonaws.com
    us-west-1:
      S3HostedZoneID: Z2F56UZL2M1ACD
      S3WebsiteEndpoint: s3-website-us-west-1.amazonaws.com
    us-west-2:
      S3HostedZoneID: Z3BJ6K6RIION7M
      S3WebsiteEndpoint: s3-website-us-west-2.amazonaws.com
    eu-west-1:
      S3HostedZoneID: Z1BKCTXD74EZPE
      S3WebsiteEndpoint: s3-website-eu-west-1.amazonaws.com
    ap-southeast-1:
      S3HostedZoneID: Z3O0J2DXBE1FTB
      S3WebsiteEndpoint: s3-website-ap-southeast-1.amazonaws.com
    ap-southeast-2:
      S3HostedZoneID: Z1WCIGYICN2BYD
      S3WebsiteEndpoint: s3-website-ap-southeast-2.amazonaws.com
    ap-northeast-1:
      S3HostedZoneID: Z2M4EHUR26P7ZW
      S3WebsiteEndpoint: s3-website-ap-northeast-1.amazonaws.com
    sa-east-1:
      S3HostedZoneID: Z31GFT0UA1I2HV
      S3WebsiteEndpoint: s3-website-sa-east-1.amazonaws.com

The <root-certificate> and <subdomain-certificate> parts define ACM certificates that match the root domain (example.com) and any sub-domains (*.example.com). I need two certificates here because *.example.com does not match example.com. (AWS documentation reference).

  RootCertificate:
    Type: 'AWS::CertificateManager::Certificate'
    Properties:
      DomainName: !Ref RootDomainName

  SubdomainCertificate:
    Type: 'AWS::CertificateManager::Certificate'
    Properties:
      DomainName: !Sub
        - '*.${Domain}'
        - Domain: !Ref RootDomainName

IMPORTANT: Before AWS will issue a certificate for your domain, it needs to verify that you own the domain and are happy for the certificate to be issued. (Otherwise, you could ask AWS top issue certificates for any old domain, which would lead to their losing their status as a trusted certificate authority).

There are two ways AWS can verify you own a domain:

  • It can send an email to an address @ your domain: reference
  • You can set a specific CNAME entry in the DNS records for the domain: reference

Now the CNAME method is the preferred way, but I couldn’t see a way of getting all of that connected up in CloudFormation – the docs suggest that it isn’t directly possible.

Email sounds at first like it would be a lot more hassle – I haven’t yet set up email for my new domain – but as luck would have it, my domain registrar (NameCheap) enables email forwarding, so the AWS verification emails simply popped up in GMail.

What happens if you don’t verify you own the domain? The stack creation or update simply sits in the *_IN_PROGRESS state indefinitely.

Onwards to buckets…

The bucket that will serve requests to the root (i.e. simply returning redirects) looks like the following:

  PublicWebsiteRootBucket:
    Type: 'AWS::S3::Bucket'
    Properties:
      BucketName: !Ref RootDomainName
      AccessControl: PublicRead
      WebsiteConfiguration:
        RedirectAllRequestsTo:
          HostName: !Ref PublicWebsiteWwwBucket

It just points to the www bucket, which looks like this:

  PublicWebsiteWwwBucket:
    Type: 'AWS::S3::Bucket'
    Properties:
      BucketName: !Sub
        - www.${Domain}
        - Domain: !Ref RootDomainName
      AccessControl: PublicRead
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: 404.html

You’ll need some other process to populate this bucket with the static files you want to serve of course – I don’t believe CloudFormation can handle that. And if you want your index and 404 files named differently, that’s up to you.

Next come the bucket policies, allowing public read on any new items (i.e. your content) that is added:

  PublicRootBucketPolicy:
      Type: 'AWS::S3::BucketPolicy'
      Properties:
        PolicyDocument:
          Id: PublicWebsitePolicy
          Version: 2012-10-17
          Statement:
            - Sid: PublicReadForGetBucketObjects
              Effect: Allow
              Principal: '*'
              Action: 's3:GetObject'
              Resource: !Join
                - ''
                - - 'arn:aws:s3:::'
                  - !Ref PublicWebsiteRootBucket
                  - /*
        Bucket: !Ref PublicWebsiteRootBucket

  PublicWwwBucketPolicy:
      Type: 'AWS::S3::BucketPolicy'
      Properties:
        PolicyDocument:
          Id: PublicWebsitePolicy
          Version: 2012-10-17
          Statement:
            - Sid: PublicReadForGetBucketObjects
              Effect: Allow
              Principal: '*'
              Action: 's3:GetObject'
              Resource: !Join
                - ''
                - - 'arn:aws:s3:::'
                  - !Ref PublicWebsiteWwwBucket
                  - /*
        Bucket: !Ref PublicWebsiteWwwBucket

Next up are the definitions for the two CloudFront distributions. As far as I can tell, a CloudFront distribution serves one and only one set of content. If you want two sets of content (as we do here), then you need two distributions.

  PublicWebsiteRootCloudfront:
    Type: AWS::CloudFront::Distribution
    DependsOn:
      - PublicWebsiteRootBucket
    Properties:
      DistributionConfig:
        Comment: CloudFront to S3 - root
        Origins:
        - DomainName: !Join
            - '.'
            - - !Ref 'RootDomainName'
              - !FindInMap [RegionMap, !Ref 'AWS::Region', S3WebsiteEndpoint]
          Id: S3RootOrigin
          CustomOriginConfig:
            HTTPPort: '80'
            HTTPSPort: '443'
            OriginProtocolPolicy: http-only
        Enabled: true
        HttpVersion: 'http2'
        DefaultRootObject: index.html
        Aliases:
          - !Ref 'RootDomainName'
        DefaultCacheBehavior:
          AllowedMethods:
          - GET
          - HEAD
          Compress: true
          TargetOriginId: S3RootOrigin
          ForwardedValues:
            QueryString: true
            Cookies:
              Forward: none
          ViewerProtocolPolicy: redirect-to-https
        PriceClass: PriceClass_All
        ViewerCertificate:
          AcmCertificateArn: !Ref RootCertificate
          SslSupportMethod: sni-only

  PublicWebsiteWwwCloudfront:
    Type: AWS::CloudFront::Distribution
    DependsOn:
      - PublicWebsiteWwwBucket
    Properties:
      DistributionConfig:
        Comment: CloudFront to S3 - www
        Origins:
        - DomainName: !Join
            - '.'
            - - 'www'
              - !Ref 'RootDomainName'
              - !FindInMap [RegionMap, !Ref 'AWS::Region', S3WebsiteEndpoint]
          Id: S3WwwOrigin
          CustomOriginConfig:
            HTTPPort: '80'
            HTTPSPort: '443'
            OriginProtocolPolicy: http-only
        Enabled: true
        HttpVersion: 'http2'
        DefaultRootObject: index.html
        Aliases:
          - !Join
            - '.'
            - - 'www'
              - !Ref 'RootDomainName'
        DefaultCacheBehavior:
          AllowedMethods:
          - GET
          - HEAD
          Compress: true
          DefaultTTL: 3600
          TargetOriginId: S3WwwOrigin
          ForwardedValues:
            QueryString: true
            Cookies:
              Forward: none
          ViewerProtocolPolicy: redirect-to-https
        PriceClass: PriceClass_All
        ViewerCertificate:
          AcmCertificateArn: !Ref SubdomainCertificate
          SslSupportMethod: sni-only

I’ve lowered DefaultTTL for the www distribution just for easier debugging. You might well want to change that value, or omit it and leave it at the default.

Finally, we come to DNS. We need a hosted zone (to hold the domain records), plus two actual domain records: one for the root, and one for www:

  HostedZone:
    Type: 'AWS::Route53::HostedZone'
    Properties:
      Name: !Ref RootDomainName

  DNS:
    Type: AWS::Route53::RecordSetGroup
    Properties:
      HostedZoneName: !Sub
        - ${Domain}.
        - Domain: !Ref RootDomainName
      RecordSets:
        - Name: !Ref 'RootDomainName'
          Type: A
          AliasTarget:
            HostedZoneId: Z2FDTNDATAQYW2
            DNSName: !GetAtt [PublicWebsiteRootCloudfront, DomainName]
        - Name: !Join
          - '.'
          - - 'www'
            - !Ref 'RootDomainName'
          Type: A
          AliasTarget:
            HostedZoneId: Z2FDTNDATAQYW2
            DNSName: !GetAtt [PublicWebsiteWwwCloudfront, DomainName]

(That hosted zone ID is the one for CloudFront: reference )

These two records are AWS-specific, and I think they are extremely confusing. Why? The two mostly-used kinds of DNS records are:

  • A records, that point to IP addresses, and;
  • CNAME records, that point to other names.

The two records above are ALIAS records – an AWS-specific bit of functionality. I think it would have been far clearer if in CloudFormation you did something like Type: ALIAS, but sadly AWS decided that instead, you would define them as Type: A, but indicate they are alias records using AliasTarget. To my eyes it looks odd having an A record that points to a name, but there we have it.

Just the Gist

If you’d like the code all in one place, you can find it here: GitHub Gist

Costs

So how much does this sort of static hosting cost?

The “big” item is hosted zones: these cost $0.50 per month each: Pricing docs.

(Note: this is a fixed cost and is not applied pro-rata. So for example, I got myself into a bit of a mess, decided to trash everything and re-create, and hence I have been billed for two hosted zones this month, even though I have only ever had one active).

If you plan on hosting multiple sites, this is likely to be your biggest cost.

So far, my other costs have been minimal. You’ll need to pay for the following:

  • S3: I’m still in the free tier, so I can’t give an estimate until next year. Remember you pay for both storage and data transfer. Pricing docs.
  • Route53: $0.40 per million queries and this does seem to be pro-rata. Pricing docs.
  • CloudFront: Again, I’m still in the free tier, so I can’t estimate yet. Pricing docs.
  • Taxes: Don’t forget these! I pay 20% VAT, but presumably this varies according to your location.
Written on December 28, 2017.