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.