DEV Community

loading...

Deploying an SPA on AWS

leonti profile image Leonti Bielski ・5 min read

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.

Discussion

pic
Editor guide