Leonti Bielski's blog

Deploying an SPA on AWS

June 22, 2020

Hosting a static website on AWS has many advantages but it has a few moving parts which need to fit together to get a fast and scalable static website.

While working on AvoCV I decided to host it on AWS for a few reasons.
Backend for the project is built using AWS Gateway, so hosting the frontend on AWS Gateway was a natural choice since it would reduce the burden of switching between different cloud providers.
Hosting a static website on AWS is also dirt-cheap, which was also important.

In this post I’ll describe the steps you need to take between creating your SPA frontend project and being able to access it from your own domain.

Domain name

The first thing you need to do is to get a domain name from any of the domain registrars.
After you have your domain you need to create a hosted zone in Route53, it costs $0.50 per month.
Route53 will create NS records automatically which you can use to “connect” your domain from your registrar to your hosted zone in Route53. Every registrar should have instructions on how to change NameServer records.

S3 hosting and Cloudfront

The easy way to make static files available publicly on AWS is to put them in a public s3 bucket. The url that you’ll get ain’t pretty though. It will look something like this: http://<bucket-name>.s3-website-us-east-1.amazonaws.com
It’s definitely not user-friendly and it’s not HTTPS which is a must nowadays.
This is why we need a Cloudfront distribution in front of the bucket. It will hide the ugly url and will make sure requests are HTTPS.

Public S3 bucket

The whole setup apart from Route53 is done in CloudFormation. This is how s3 bucket setup looks like:

S3BucketLogs:
  Type: AWS::S3::Bucket
  DeletionPolicy: Delete
  Properties:
    AccessControl: LogDeliveryWrite
    BucketName: !Sub "${AWS::StackName}-logs"

S3BucketRoot:
  Type: AWS::S3::Bucket
  DeletionPolicy: Delete
  Properties:
    AccessControl: PublicRead
    BucketName: !Sub "${AWS::StackName}-root"
    LoggingConfiguration:
      DestinationBucketName: !Ref S3BucketLogs
      LogFilePrefix: "cdn/"
    WebsiteConfiguration:
      ErrorDocument: "index.html"
      IndexDocument: "index.html"

S3BucketPolicy:
  Type: AWS::S3::BucketPolicy
  Properties:
    Bucket: !Ref S3BucketRoot
    PolicyDocument:
      Version: "2012-10-17"
      Statement:
        - Effect: "Allow"
          Action: "s3:GetObject"
          Principal: "*"
          Resource: !Sub "${S3BucketRoot.Arn}/*"

${AWS::StackName} will resolve to the name of your stack, so if you name your stack my-frontend the name of the bucket will be my-frontend-root.
We are telling AWS that the bucket should be publicly accessible with PublicRead access control and with a BucketPolicysaying that every object should be public.
This setup even includes access logs which are being put into a special <stack-name>-logs bucket. Why ErrorDocument and IndexDocument are the same?
This is because in an SPA all requests go to index.html and then JavaScript decided which route to show.
When you navigate to https://avocv.com/editor AWS will try to find editor file in the bucket, but it won’t be there. Normally it would return an error page, but we are overriding it with ErrorDocument: index.html so it loads index.html anyway. Once the page is loaded JavaScript looks the url in the browser, sees /editor and knows that it needs to load Editor page.
If this behaviour is not desirable you can change this entry to something else, for example, error.html. Just make sure error.html is in the bucket.

SSL Certificate

Creating SSL in CloudFormation is easy:

CertificateManagerCertificate:
  Type: AWS::CertificateManager::Certificate
  Properties:
    DomainName: !Ref DomainName
    SubjectAlternativeNames:
      - !Sub www.${DomainName}
    ValidationMethod: DNS

The only “gotcha” is that the SSL certificate will need to be verified (you’ll have to add verification entries to your domain records). You will see a pending certificate in AWS Console and since your hosted zone is in Route53 it’s as easy as pressing a button.

CloudFront SSL-only distribution

Creating a CloudFront distribution is a little bit more involved:

CloudFrontDistribution:
  Type: AWS::CloudFront::Distribution
  Properties:
    DistributionConfig:
      Aliases:
        - !Ref DomainName
      CustomErrorResponses:
        - ErrorCachingMinTTL: 60
          ErrorCode: 404
          ResponseCode: 200
          ResponsePagePath: "/index.html"
        - ErrorCachingMinTTL: 60
          ErrorCode: 403
          ResponseCode: 200
          ResponsePagePath: "/index.html"
      DefaultCacheBehavior:
        AllowedMethods:
          - GET
          - HEAD
        CachedMethods:
          - GET
          - HEAD
        Compress: true
        DefaultTTL: 86400
        ForwardedValues:
          Cookies:
            Forward: none
          QueryString: true
        MaxTTL: 31536000
        SmoothStreaming: false
        TargetOriginId: !Sub "S3-${AWS::StackName}-root"
        ViewerProtocolPolicy: "redirect-to-https"
      DefaultRootObject: "index.html"
      Enabled: true
      HttpVersion: http2
      IPV6Enabled: true
      Logging:
        Bucket: !GetAtt S3BucketLogs.DomainName
        IncludeCookies: false
        Prefix: "cdn/"
      Origins:
        - CustomOriginConfig:
            HTTPPort: 80
            HTTPSPort: 443
            OriginKeepaliveTimeout: 5
            OriginProtocolPolicy: "https-only"
            OriginReadTimeout: 30
            OriginSSLProtocols:
              - TLSv1
              - TLSv1.1
              - TLSv1.2
          DomainName: !GetAtt S3BucketRoot.DomainName
          Id: !Sub "S3-${AWS::StackName}-root"
      PriceClass: PriceClass_All
      ViewerCertificate:
        AcmCertificateArn: !Ref CertificateManagerCertificate
        SslSupportMethod: sni-only

We are using the same trick with custom error pages so they all would return index.html.
By setting an alias we are making the public s3 bucket accessible from our domain.
SSL is configured to use the certificate created in the same stack for our custom domain.

Redirecting WWW users to a naked domain

This step is optional, but personally I prefer to have a non-www domains, so I’d like to redirect users coming to https://www.avocv.com to https://avocv.com. For this we’ll need a special s3 bucket and another CloudFront distribution:

S3BucketWWW:
  Type: "AWS::S3::Bucket"
  Properties:
    BucketName: !Sub "${AWS::StackName}-www-redirect"
    AccessControl: PublicRead
    WebsiteConfiguration:
      RedirectAllRequestsTo:
        HostName: !Sub ${DomainName}
        Protocol: https

CloudFrontDistributionRedirect:
  Type: AWS::CloudFront::Distribution
  Properties:
    DistributionConfig:
      Origins:
        - DomainName: !Sub "${AWS::StackName}-www-redirect.s3-website-${AWS::Region}.amazonaws.com"
          Id: !Sub "S3-${AWS::StackName}-www-redirect"
          CustomOriginConfig:
            OriginProtocolPolicy: http-only
      Enabled: true
      HttpVersion: http2
      IPV6Enabled: true
      Logging:
        Bucket: !GetAtt S3BucketLogs.DomainName
        IncludeCookies: false
        Prefix: "cdn-redirects/"
      Aliases:
        - !Sub "www.${DomainName}"
      DefaultCacheBehavior:
        AllowedMethods:
          - GET
          - HEAD
        CachedMethods:
          - GET
          - HEAD
        TargetOriginId: !Sub "S3-${AWS::StackName}-www-redirect"
        Compress: True
        DefaultTTL: 604800
        ForwardedValues:
          QueryString: "false"
          Cookies:
            Forward: none
        ViewerProtocolPolicy: redirect-to-https
      PriceClass: PriceClass_All
      ViewerCertificate:
        AcmCertificateArn: !Ref CertificateManagerCertificate
        SslSupportMethod: sni-only

Public S3 buckets have ability to redirect user requests. In our case we create an empty public bucket which would redirect all requests to it to https://<your-domain-name>, so now all we have to do is to make sure all requests from www.<your-domain-name> would end up in that bucket which would then redirect them to a naked domain <your-domain-name>.
We do this with another simplified CloudFront distribution which has a www alias www.${DomainName}.
What this means is that when users navigates to www.avocv.com request will be handled by a redirect CloudFront distribution which will send it to a redirect bucket, which in turn will redirect it to avocv.com and it will be picked up by the proper CloudFront distribution and the request will end up in the destination s3 bucket. That’s a lot of redirects!

Route53 records

The last thing we need to do is to tell Route53 that domain is “connected” to our CloudFront distributions:

Route53RecordSetGroup:
  Type: AWS::Route53::RecordSetGroup
  Properties:
    HostedZoneName: !Sub "${DomainName}."
    RecordSets:
      - Name: !Ref DomainName
        Type: A
        AliasTarget:
          DNSName: !GetAtt CloudFrontDistribution.DomainName
          EvaluateTargetHealth: false
          HostedZoneId: Z2FDTNDATAQYW2
      - Name: !Sub "www.${DomainName}"
        Type: A
        AliasTarget:
          DNSName: !GetAtt CloudFrontDistributionRedirect.DomainName
          EvaluateTargetHealth: false
          HostedZoneId: Z2FDTNDATAQYW2

Please note that HostedZoneId doesn’t refer to your domain’s zone id, it a special zone id (Z2FDTNDATAQYW2) for an alias record which points to a CloudFront distribution.
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-aliastarget.html

Full setup

You can find the full setup in this repository: https://github.com/Leonti/aws-static-website It also includes an example deploy script:

aws s3 sync --delete --acl "public-read" build s3://<stack-name>-root
aws cloudfront create-invalidation --distribution-id <distribution-id> --paths "/index.html"

It will sync your files with s3 bucket and invalidate index.html which is the only file you need to invalidate when building a modern SPA.

© 2021, Leonti Bielski