Deploying an SPA on AWS
Tags: aws, cloudfront, cloudformation, spa
Published: 2020-06-22 07:47:11

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](https://avocv.com) 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](https://console.aws.amazon.com/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:
```yaml
  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:

```yaml
  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:
```yaml
  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:

```yaml
  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:
```yaml
  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:
```bash
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.