HTTPS Static Site Hosting in S3
In my last post I mentioned that I wanted to move my site to HTTPS. I initially intended to write about my experience doing so, but instead, I feel like a more general “HOW TO” style would be more useful for people.
This ended up being a fairly long post, if you just want a CloudFormation template without understanding what’s in it, you can find it here.
Why HTTPS for a static site?
If you’re reading this, then I’m assuming that you’ve already made the decision to enable HTTPS on your site. But in case you haven’t, the EFF has a fantastic resource on why it’s important, or you can also check out Why HTTPS matters from Google’s, Web Fundamentals.
For me, the reason I want to make my static site HTTPS is because I believe that the web should be encrypted by default. And I want to contribute to that goal instead of working against it.
What we’ll be doing
To host a static site in S3 with HTTPS, we’ll need a few things. We’ll need:
- A static site that we want to deploy
- An AWS account
- A registered domain name and access to the email address in the WHOIS record for the domain name.
We’ll be setting up a way to run our provisioning and deployment scripts (we’ll try to automate as much of this as possible). I’m also going to set up continuous deployment with CircleCI, because it makes it easier. I’m switching from Github pages, so it will also make my publishing pipeline more consistent.
All up, it should cost next to nothing, perhaps a dollar a month depending on your site traffic. This includes a free TLS certificate, and CloudFront CDN. It’s a little bit more expensive than free, but not by much.
Step 1 - Provisioning All The Things!
We’re going to be using CloudFormation to provision everything we’ll need for our HTTPS static site. This includes:
- The S3 bucket
- CloudFront CDN
- The TLS certificate
- Route53 DNS routes for our site
We could set everything up manually in the AWS console, but it’s far more repeatable to do it with CloudFormation, which will be useful for the next site we want to provision.
The CloudFormation template has the following parts:
Parameters
CloudFormation parameters are a good way to reduce duplication in our template. We’ll keep ours pretty simple:
Parameters:
RootDomainName:
Type: String
Description: The root domain name for our site, e.g. `example.com`
Resources
AWS::S3::Bucket and AWS::S3:BucketPolicy
We’ll need to deploy our static site somewhere, that’s what this bucket will be. Typically, S3 buckets for static sites need to have the same name as the site, but in this case we’re going to be putting a CloudFront CDN in front of it, so it’s more of a formality. It will make it easier to identify your bucket in the future though. Our BucketPolicy is what allows all of our files to be accessed publicly. Without it, files will be private by default, and requests to our pages will return a 403 response. The CloudFormation for these resources looks like:
SiteS3Bucket:
Type: "AWS::S3::Bucket"
Properties:
AccessControl: PublicRead
WebsiteConfiguration:
IndexDocument: index.html
ErrorDocument: error.html
BucketName: !Ref RootDomainName
SiteBucketPolicy:
Type: "AWS::S3::BucketPolicy"
Properties:
Bucket: !Ref SiteS3Bucket
PolicyDocument:
Statement:
- Sid: ReadAccess
Action: s3:GetObject
Effect: Allow
Resource: !Join
- ""
-
- "arn:aws:s3:::"
- !Ref SiteS3Bucket
- "/*"
Principal: "*"
AWS::CertificateManager::Certificate
This is our TLS/SSL certificate. AWS provides free certificates with their Certificate Manager service. The only thing you’ll need to do is verify that you own the domain. In order to do this, they’ll send an email to the addresses listed on your WHOIS record, along with some other “common system administration addresses”. We can request one of these certificates with:
TLSCertificate:
Type: "AWS::CertificateManager::Certificate"
Properties:
DomainName: !Ref RootDomainName
SubjectAlternativeNames:
- !Join
- "."
-
- "www"
- !Ref RootDomainName
What we’re requesting here is a certificate for our RootDomainName
and the www
subdomain. When we run this, AWS
will create a TLS/SSL certificate for our domain with the “Pending validation” status. We should also receive our
domain ownership verification email(s), and CloudFormation will wait for us to verify domain ownership before
continuing. Just open the email for the root domain and follow the instructions.
AWS::CloudFront::Distribution
CloudFront CDN does a few things. It lets us use our TLS certificate with our website, and also acts as a CDN, caching our pages and serving them to our users around the world. The CloudFront part of the template is probably the most complicated:
SiteCDN:
Type: "AWS::CloudFront::Distribution"
Properties:
DistributionConfig:
Enabled: true
Aliases:
- !Ref RootDomainName
Origins:
- Id: S3Bucket
DomainName: !GetAtt
- SiteS3Bucket
- DomainName
S3OriginConfig: {}
HttpVersion: "http2"
ViewerCertificate:
AcmCertificateArn: !Ref TLSCertificate
SslSupportMethod: "sni-only"
DefaultRootObject: "index.html"
DefaultCacheBehavior:
TargetOriginId: S3Bucket
ViewerProtocolPolicy: "redirect-to-https"
MaxTTL: 86400
ForwardedValues:
QueryString: false
CustomErrorResponses:
- ErrorCachingMinTTL: 0
ErrorCode: 400
ResponseCode: 400
ResponsePagePath: /error.html
- ErrorCachingMinTTL: 0
ErrorCode: 403
ResponseCode: 403
ResponsePagePath: /error.html
- ErrorCachingMinTTL: 0
ErrorCode: 404
ResponseCode: 404
ResponsePagePath: /error.html
- ErrorCachingMinTTL: 0
ErrorCode: 500
ResponseCode: 500
ResponsePagePath: /error.html
- ErrorCachingMinTTL: 0
ErrorCode: 503
ResponseCode: 503
ResponsePagePath: /error.html
This template will provision a CloudFront distribution that essentially proxies our S3 bucket. A request to
/path/to/file.html
will serve the same file from the S3 bucket. We’re also telling CloudFront to use our newly
provisioned TLS Certificate when serving HTTPS
requests, and redirecting any HTTP
requests to HTTPS
.
We need to explicitly tell CloudFront to respond to a request made to /
with the /index.html
file, and we also need
to tell CloudFront how to respond to HTTP error codes coming from the bucket. This has to be done for each different
4xx
or 5xx
HTTP code we can get, but fortunately the
list of response codes that S3 will respond with
is relatively small. You can use a different error page for each code, or be lazy like me and use the same one for
everything.
CloudFront takes a while to deploy out to all of the CDN edge nodes, so the CloudFormation provisioning will take a while (it took around 20 minutes for me) when you include this part, but just be patient and it will eventually complete.
AWS::Route53::HostedZone and AWS::Route53::RecordSetGroup
At this point, it’s certainly possible to just stop and manually configure our DNS with a CNAME record pointing to
our CloudFront distribution. However, Route53 lets us provision this alongside the rest of our setup, which means we’ll
have less manual configuration. We’ll also set up DNS Records for both our root domain, and the www
subdomain to
direct them to our CloudFront distribution.
SiteHostedZone:
Type: "AWS::Route53::HostedZone"
Properties:
Name: !Ref RootDomainName
SiteDNSRecord:
Type: "AWS::Route53::RecordSetGroup"
Properties:
HostedZoneId: !Ref SiteHostedZone
RecordSets:
- Name: !Ref RootDomainName
Type: A
AliasTarget:
DNSName: !GetAtt
- SiteCDN
- DomainName
HostedZoneId: Z2FDTNDATAQYW2
- Name: !Join
- "."
-
- "www"
- !Ref RootDomainName
Type: CNAME
TTL: 600
ResourceRecords:
- !Ref RootDomainName
After running this, we end up with a HostedZone for our domain, along with an A record which points to our CloudFront
distribution (using Route53’s AliasTarget
). We’ll also have a CNAME record directing the www
subdomain to our
RootDomainName
, with a TTL of 10 minutes.
Note: The HostedZoneId
under our AliasTarget
. Z2FDTNDATAQYW2
is CloudFront’s HostedZoneId
, and should be the same for any AliasTarget
pointing at a CloudFront distribution.
Outputs
CloudFormation lets us specify outputs which we can retrieve with the aws-cli
, and easily to find later in the AWS
Console. We can use this to give us the URL to our newly provisioned site, as well as telling us the NameServers we’ll
need for our Domain Registrar. We’ll also output our Cloudfront Distribution ID which we’ll use later during deployment.
Outputs:
WebsiteURL:
Description: URL for the website
Value: !Join
- ""
-
- "https://"
- !Ref RootDomainName
NameServers:
Description: DNS NameServers for the site
Value: !Join
- ", "
- !GetAtt
- SiteHostedZone
- NameServers
CloudFrontDistributionId:
Description: The CloudFront Distribution Id to be used for creating invalidations during deployment
Value: !Ref SiteCDN
Putting it all together
What we end up with is a single CloudFormation template with our Parameters
, Outputs
, and the above Resources
.
We can then provision it by uploading it in the AWS Console, or by using the aws-cli
Instead of putting it all here again, I’ve committed it along with a simple provisioning script over on my Github
Step 2 - Deploying
Deploying our site is a relatively simple task. We simply build our site, then sync the compiled files to S3. I’m going to explain how I’ve done it for this site (given how it’s set up), but the idea should be the same for any static site.
So that we know exactly where our built files are going to end up, it’s a good idea to specify this when we build (if
possible). In Jekyll we can pass the destination directory as a build option. I’m going to use an environment variable
so that we can re-use it again when we sync to S3. My personal preference for this directory name is public
, but you
can use whatever you want.
Also, because we’re using CloudFront to cache our pages, we can create an invalidation as part of our deploy. Otherwise, we’ll have to wait a few minutes for any changes to appear on our site.
BUILD_DIR="public"
SITE_URL="example.com"
CLOUDFRONT_DISTRIBUTION_ID=<this-comes-from-the-stack-output>
echo "--- Build Site"
bundle exec jekyll build --destination $BUILD_DIR
echo "--- Sync ${BUILD_DIR} to S3"
aws s3 sync $BUILD_DIR "s3://${SITE_URL}" --delete
echo "--- Invalidating CloudFront cache"
aws cloudfront create-invalidation --distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" --paths "/*"
Step 3 - Use Route53 NameServers
That’s it! You should now be able to set your Domain to use the Route53 NameServers that we output from our CloudFormation stack. How you do this depends on your registrar, but you should be able to find instructions relatively easily. This can take a bit of time to propagate through, but once it does you should have an HTTPS static site deployed to AWS for less than the price of… whatever you can get for a dollar these days, a postage stamp maybe. Either way, it isn’t much.