Create a static site on AWS with CloudFormation
How to create a static site in AWS with a custom domain, SSL certificate, and proper redirects with a single CLI command.
A short story about Monithor's static website
click here to jump straight to the technical part
Leaving the landing page by the very end, when in my acceptance the project is "ready" to launch is a mistake I did way too many times in the past. Nearly all my abandoned projects were code-first, marketing-later.
The pattern is quite common among fellow developers: buy a domain ⟶ code like crazy for a few weeks ⟶ run out of steam / find a new project idea ⟶ abandon the project ⟶ repeat. Very few projects even have the chance to get in front of potential users.
Although this article is published as the 3rd in the series, the landing page was the first thing I did for Monithor. A landing page helps you look at things from the other side of the fence, and writing the copy gives clarity and helps better define the scope.
I wanted to avoid getting lost in details and spend a lot of time designing & coding the template for the landing page, so I went straight to HTMLRev and got a great-looking template. I highly recommend Lucian's work, crazy attention to detail, and the code quality is great.
The logo and the color palette are from logology, they have a really fun and quick process and the result is great.
I glued the pieces together, wrote the copy, and a day later I had a great-looking landing page that was ready to be deployed. I usually deploy static sites to Netlify, but as Monithor will be built entirely on top of AWS it made more sense to have it all in one place.
Deploy to AWS
Deploying a static website to AWS might sound simple, but if you want a custom domain with an SSL certificate, and a proper non-www to www redirect, things get messy. It's not complicated, but it's a very cumbersome process to set it up from the AWS console, I did it so many times that I got fed up.
As a developer, fan of Infrastructure as Code (IaC), I decided to create a CloudFormation template that will save you time in the future.
The standard way
But first, let me describe the standard way of setting up a static website on AWS to understand why I want to avoid it in the future:
- create two S3 buckets, one for the naked domain (non-www) and one for the www
- enable static website hosting on buckets
- set the non-www bucket to redirect requests to the www domain (or the other way around)
- request a certificate to cover both the non-www and the www domain
- add CNAMES in the Route53 hosted zone to validate the domain for the certificate
- wait for the certificate to be issued
- create two CloudFront distributions, one for each of the buckets
- make sure each distribution Origin points to the bucket website endpoint, not the bucket URL
- go back to Route53 and create alias entries to point the domain to the CloudFront distros
- done, if we didn't make any mistake
The easy way
The CloudFormation template will save you time and frustration:
- creates a single S3 bucket with the required policy
- creates a single CloudFront distribution and sets the proper Origin
- issues and installs the SSL certificate
- automates all the required Route53 DNS records
- manages redirects with a CloudFront Function
You simply type npm run create-stack
command in the terminal and wait for the magic to happen. It will take about 5 minutes to complete though, you can monitor the progress from the AWS Console
You just need to:
- have the domain purchased and pointed to the hosted zone created for it in AWS Route53 before creating the stack
- have AWS CLI installed and a user with AdministratorAccess
Create the stack
The create-stack
command is as below:
aws cloudformation create-stack \
--stack-name STACK_NAME \
--template-body file://template.yml \
--profile AWS_PROFILE \
--region us-east-1 \
--parameters ParameterKey=DomainName,ParameterValue=DOMAIN_NAME ParameterKey=HostedZoneId,ParameterValue=HOSTED_ZONE_ID
Where
STACK_NAME
- is your stack nameAWS_PROFILE
- is your credentials profile in theDOMAIN_NAME
- is your domain name (the naked domain, e.g. example.com)HOSTED_ZONE_ID
- is your Route53 zone for the domain, should look like Z07414553OA4KK51T5EZA
Deploy the site
The deploy
command will deploy your static site to the S3 bucket and invalidate the CloudFront cache. You may update as per your needs, by default the copy command excludes all paths in the current folder, in order to prevent deploying sensitive info.
aws s3 cp ./ s3://S3_BUCKET_NAME \
--acl public-read \
--profile AWS_PROFILE \
--recursive \
--exclude "*" \
--include index.html \
--include 404.html \
&& aws cloudfront create-invalidation \
--profile monithor \
--distribution-id DISTRIBUTION_ID \
--paths "/*"
Where:
S3_BUCKET_NAME
- is your S3 bucket nameAWS_PROFILE
- is your credentials profileDISTRIBUTION_ID
- is your CloudFront distribution ID
And here's the CloudFormation template:
Description: Deploy a static site
Parameters:
DomainName:
Description: Domain name
Type: String
HostedZoneId:
Description: Hosted Zone ID
Type: String
Resources:
S3Bucket:
Type: AWS::S3::Bucket
DeletionPolicy: Delete
Properties:
AccessControl: PublicRead
BucketName: !Sub "${AWS::StackName}"
WebsiteConfiguration:
ErrorDocument: "404.html"
IndexDocument: "index.html"
S3BucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref S3Bucket
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Action: "s3:GetObject"
Principal: "*"
Resource: !Sub "${S3Bucket.Arn}/*"
#
CertificateManagerCertificate:
Type: AWS::CertificateManager::Certificate
Properties:
# naked domain
DomainName: !Ref DomainName
# add www to certificate
SubjectAlternativeNames:
- !Sub "www.${DomainName}"
ValidationMethod: DNS
DomainValidationOptions:
# DNS record for the naked domain
- DomainName: !Ref DomainName
HostedZoneId: !Ref HostedZoneId
# DNS record for the www domain
- DomainName: !Sub "www.${DomainName}"
HostedZoneId: !Ref HostedZoneId
#
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Aliases:
- !Ref DomainName
- !Sub "www.${DomainName}"
CustomErrorResponses:
- ErrorCachingMinTTL: 60
ErrorCode: 404
ResponseCode: 404
ResponsePagePath: "/404.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}"
ViewerProtocolPolicy: "redirect-to-https"
FunctionAssociations:
- EventType: viewer-request
FunctionARN: !GetAtt RedirectFunction.FunctionMetadata.FunctionARN
DefaultRootObject: "index.html"
Enabled: true
HttpVersion: http2
IPV6Enabled: true
Origins:
- CustomOriginConfig:
HTTPPort: 80
HTTPSPort: 443
OriginKeepaliveTimeout: 5
# keep http-only to avoid 504 errors after stack creation
OriginProtocolPolicy: "http-only"
OriginReadTimeout: 30
OriginSSLProtocols:
- TLSv1
- TLSv1.1
- TLSv1.2
#Bucket website endpoint without http://
DomainName: !Join
- ""
- - !Ref S3Bucket
- ".s3-website-"
- !Ref AWS::Region
- ".amazonaws.com"
Id: !Sub "S3-${AWS::StackName}"
PriceClass: PriceClass_All
ViewerCertificate:
AcmCertificateArn: !Ref CertificateManagerCertificate
MinimumProtocolVersion: TLSv1.1_2016
SslSupportMethod: sni-only
RedirectFunction:
Type: AWS::CloudFront::Function
Properties:
AutoPublish: true
Name: !Sub "${AWS::StackName}-redirects"
# add the config, even if optional, the stack creation will thrown InternalFailure error otherwise
FunctionConfig:
Comment: !Sub "Redirect to ${DomainName}"
Runtime: cloudfront-js-1.0
FunctionCode: !Sub |
function handler(event) {
//
var request = event.request;
var host = request.headers.host.value;
if (!host.startsWith("www.")) {
return {
statusCode: 301,
statusDescription: "Permanently moved",
headers: {
location: { value: "https://www." + host },
},
};
}
return request;
}
Route53RecordSetGroup:
Type: AWS::Route53::RecordSetGroup
Properties:
# keep the . suffix
HostedZoneName: !Sub "${DomainName}."
# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-aliastarget.html#cfn-route53-aliastarget-hostedzoneid
RecordSets:
- Name: !Ref DomainName
Type: A
AliasTarget:
DNSName: !GetAtt CloudFrontDistribution.DomainName
EvaluateTargetHealth: false
HostedZoneId: Z2FDTNDATAQYW2 # leave hardcoded, don't confuse w/ !Ref HostedZoneId
- Name: !Sub "www.${DomainName}"
Type: A
AliasTarget:
DNSName: !GetAtt CloudFrontDistribution.DomainName
EvaluateTargetHealth: false
HostedZoneId: Z2FDTNDATAQYW2 # leave hardcoded, don't confuse w/ !Ref HostedZoneId
Outputs:
WebsiteURL:
Value: !GetAtt S3Bucket.WebsiteURL
Description: URL for website hosted on S3
CloudfrontDomainName:
Value: !GetAtt CloudFrontDistribution.DomainName
Truth be told, it looks much more complex than it is. I also created a GitHub repository with the template so you can get started quicker. Check it out.
Credits
About
Monithor is a web service monitoring tool that helps you check websites or APIs' uptime and functionality and be alerted when they don't work as expected. It's built on AWS using the Serverless framework and it's open-source