Static Sites

With S3, CloudFront and ACM

We often have static web content that we want to serve on the internet. This might be a standard HTML-based website for blogging. Or it might be part of a modern web app where we have a JavaScript-based React or Vue.js app that interacts with an API app.

In both cases there are many advantages to using S3 – AWS Simple Storage Service – for the static content.

When content is completely static, it is extremely reliable to store and cost-effective to deliver to users. Furthermore this simplifies our API app. Now it is only concerned with data, not content, making it easier to write and more cost-effective to run.

Compare this to a traditional Model View Controller (MVC) app approach like Rails or Django. In this architecture the API may spend lots of time rendering HTML, and the HTML may not get served to users if there is an application bug or a database outage.

Static websites are a solved problem on AWS. We simply create an S3 bucket configured for website hosting and upload the content with public-read permissions. Then anyone can access the content from a URL like http://www.mixable.net.s3-website-us-east-1.amazonaws.com with some of the highest reliability and lowest storage and bandwidth costs possible.

Serving this from a custom domain is also a solved problem. We add the CloudFront CDN, configured with an SSL cert via the AWS Certificate Manager, in front of the S3 bucket. When we point our DNS to CloudFront, users can access the content from a URL like https://www.mixable.net with some of the fastest delivery times and lowest bandwidth costs possible thanks to the global content caching network.

Let’s set this all up for our app…

Note: This is part of a series about writing and Go Functions-as-a-Service on AWS Lambda and related services like API Gateway, S3 and X-Ray.

If you’d like to experiment with these techniques yourself, check out https://github.com/nzoschke/gofaas for a boilerplate app with all the pieces configured correctly and explained in depth.

AWS Config

There are a lot of configuration options for S3 and CloudFront so the template isn’t short. But rest assured this template will keep your website running forever.

Note that we add a WebsiteConfiguration for the S3 bucket, and conditionally create an ACM cert and CloudFront distribution if we specify the WebDomainName parameter.

---
AWSTemplateFormatVersion: '2010-09-09'

Conditions:
  WebDomainNameSpecified: !Not [!Equals [!Ref WebDomainName, ""]]

Mappings:
  RegionMap:
    ap-northeast-1:
      S3HostedZoneId: Z2M4EHUR26P7ZW
      S3WebsiteEndpoint: s3-website-ap-northeast-1.amazonaws.com
    ap-southeast-1:
      S3HostedZoneId: Z3O0J2DXBE1FTB
      S3WebsiteEndpoint: s3-website-ap-southeast-1.amazonaws.com
    ap-southeast-2:
      S3HostedZoneId: Z1WCIGYICN2BYD
      S3WebsiteEndpoint: s3-website-ap-southeast-2.amazonaws.com
    eu-west-1:
      S3HostedZoneId: Z1BKCTXD74EZPE
      S3WebsiteEndpoint: s3-website-eu-west-1.amazonaws.com
    sa-east-1:
      S3HostedZoneId: Z31GFT0UA1I2HV
      S3WebsiteEndpoint: s3-website-sa-east-1.amazonaws.com
    us-east-1:
      S3HostedZoneId: Z3AQBSTGFYJSTF
      S3WebsiteEndpoint: s3-website-us-east-1.amazonaws.com
    us-west-1:
      S3HostedZoneId: Z2F56UZL2M1ACD
      S3WebsiteEndpoint: s3-website-us-west-1.amazonaws.com
    us-west-2:
      S3HostedZoneId: Z3BJ6K6RIION7M
      S3WebsiteEndpoint: s3-website-us-west-2.amazonaws.com

Outputs:
  WebDistributionDomainName:
    Condition: WebDomainNameSpecified
    Value: !GetAtt WebDistribution.DomainName

  WebUrl:
    Value:
      !If
      - WebDomainNameSpecified
      - !Sub https://${WebDomainName}
      - !Sub
        - http://${WebBucket}.${Endpoint}
        - {Endpoint: !FindInMap [RegionMap, !Ref "AWS::Region", S3WebsiteEndpoint]}

Parameters:
  WebDomainName:
    Default: ""
    Description: "Domain or subdomain for the static website distribution, e.g. www.gofaas.net"
    Type: String

Resources:
  WebBucket:
    DeletionPolicy: Retain
    Properties:
      AccessControl: PublicRead
      BucketName: !If [WebDomainNameSpecified, !Ref WebDomainName, !Sub "${AWS::StackName}-webbucket-${AWS::AccountId}"]
      WebsiteConfiguration:
        ErrorDocument: 404.html
        IndexDocument: index.html
    Type: AWS::S3::Bucket

  WebBucketPolicy:
    Properties:
      Bucket: !Ref WebBucket
      PolicyDocument:
        Statement:
          - Action: s3:GetObject
            Effect: Allow
            Principal: "*"
            Resource: !Sub arn:aws:s3:::${WebBucket}/*
            Sid: PublicReadForGetBucketObjects
    Type: AWS::S3::BucketPolicy

  WebCertificate:
    Condition: WebDomainNameSpecified
    Properties:
      DomainName: !Ref WebDomainName
    Type: AWS::CertificateManager::Certificate

  WebDistribution:
    Condition: WebDomainNameSpecified
    Properties:
      DistributionConfig:
        Aliases:
          - !Ref WebDomainName
        Comment: !Sub Distribution for ${WebBucket}
        DefaultCacheBehavior:
          AllowedMethods:
            - GET
            - HEAD
          Compress: true
          ForwardedValues:
            Cookies:
              Forward: none
            QueryString: true
          TargetOriginId: !Ref WebBucket
          ViewerProtocolPolicy: redirect-to-https
        DefaultRootObject: index.html
        Enabled: true
        HttpVersion: http2
        Origins:
          - CustomOriginConfig:
              HTTPPort: 80
              HTTPSPort: 443
              OriginProtocolPolicy: http-only
            DomainName: !Sub
              - ${WebBucket}.${Endpoint}
              - {Endpoint: !FindInMap [RegionMap, !Ref "AWS::Region", S3WebsiteEndpoint]}
            Id: !Ref WebBucket
        PriceClass: PriceClass_All
        ViewerCertificate:
          AcmCertificateArn: !Ref WebCertificate
          SslSupportMethod: sni-only
    Type: AWS::CloudFront::Distribution

From template.yml

Deploy

Now we can deploy the config to create the website bucket:

$ aws cloudformation package \
    --output-template-file out.yml --template-file template.yml

$ aws cloudformation deploy --stack-name mixable \
    --capabilities CAPABILITY_NAMED_IAM --template-file out.yml
Waiting for stack create/update to complete

$ aws cloudformation describe-stacks --output text --query 'Stacks[*].Outputs' --stack-name mixable
WebUrl	http://mixable-webbucket-572007530218.s3-website-us-east-1.amazonaws.com

From Makefile

And upload our first content:

$ aws s3 sync public s3://mixable-webbucket-572007530218/
upload: public/index.html to s3://gofaas-webbucket-572007530218/index.html
...

From Makefile

Sure enough we can access it over HTTP:

$ curl http://mixable-webbucket-572007530218.s3-website-us-east-1.amazonaws.com/
...
<title>My first Vue app</title>

Deploy Custom Domain

Now we can re-deploy the config with our domain name to create the certificate and CDN:

aws cloudformation deploy --stack-name mixable          \
    --parameter-overrides WebDomainName=www.mixable.net \
    --capabilities CAPABILITY_NAMED_IAM --template-file out.yml

$ aws cloudformation describe-stacks  --stack-name mixable \
    --output text --query 'Stacks[*].Outputs'
WebDistributionDomainName  d2bwnae7bzw1t6.cloudfront.net
WebUrl                     https://www.mixable.net

Note that this can take 10 to 20 minutes to set up the global infrastructure for our static site. Also note that ACM will send an email to the domain owner (e.g. admin@mixable.net) who must click through the approval to create the certificate. See the ACM email validation guide for more information.

Once the CDN is in place, we can sync content to the S3 bucket the same way, but we may need to invalidate content cached in the CDN to immediately see the latest content:

$ aws s3 sync public s3:/www.mixable.net/
$ aws cloudfront create-invalidation --distribution-id E2YL0GMGANCGMA --paths '/*'

Sure enough we can access our content via the CDN:

$ curl https://d2bwnae7bzw1t6.cloudfront.net/
...
<title>My first Vue app</title>

DNS

The final step is to set up a DNS CNAME from our WebDomainName parameter (e.g. www.mixable.net) to the new WebDistributionDomainName output (e.g. d2bwnae7bzw1t6.cloudfront.net).

If we are using Route53, this is easy to do through the UI:

alt text

In this case we could consider automating DNS setup by adding an conditional AWS::Route53::RecordSet resource to our template. We could also consider using ACM DNS validation to fully automate the certificate.

After a few minutes we have our custom HTTPS web endpoint:

$ curl https://www.mixable.net
...
<title>My first Vue app</title>

Summary

When hosting a static site an app with S3, CloudFront and ACM we can:

  • Store our static web content for low cost
  • Access cached web content quickly via a custom domain
  • Automate cert creation and renewal

We no longer have to worry about:

  • Configuring HTTP servers
  • Generating HTML content in our API

Our app is easier to build and more reliable and cost effective to run.