There are a lot of ways of hosting a static website. I chose to use AWS as a hosting platform for my website primarily for learning purposes, since I’ve been working on several AWS certifications. I created the resources for my static website using an AWS Serverless Application Model (SAM) template. AWS SAM templates are an extension to CloudFormation templates that provide a simpler syntax for defining serverless resources, such as Lambda functions. Every resource for this solution is included in the SAM template, with the exception of the Route 53 Hosted Zone. The SAM template and associated Lambda function for this solution can be found here.

The simplest way of hosting a static website in AWS is to create an S3 bucket, allow public access to it, and enable the static website hosting feature. One issue with this approach is that S3 static website hosting does not support SSL. Additionally, each request will have to fetch data from the S3 bucket directly. For large files (ex. images and videos), this can present issues.

The solution I present uses CloudFront with an S3 bucket as its origin. CloudFront is a CDN that caches data at AWS edge locations, which increases performance for frequently accessed files. Additionally, CloudFront supports SSL certificates, although we’ll have to create our own when using a custom domain name.

Let’s start by declaring some inputs to the SAM template:

Parameters:
  DomainName:
    Type: String
    Description: Domain name
  HostedZoneId:
    Type: String
    Description: Hosted zone ID
  CFDistributionPriceClass:
    Type: String
    Default: PriceClass_All
    AllowedValues:
      - PriceClass_All
      - PriceClass_100
      - PriceClass_200
    Description: Price class for CloudFront distribution

This declares three inputs: a domain name, a Route 53 Hosted Zone ID, and a CloudFront distribution price class. As I mentioned earlier, the Route 53 Hosted Zone is intentionally kept outside of the template. Next, we’ll need two S3 buckets: one for the website content, and another for the CloudFront distribution logs:

WebsiteBucket:
  Type: AWS::S3::Bucket
  Properties:
    BucketName: !Ref DomainName
    BucketEncryption:
      ServerSideEncryptionConfiguration:
        - ServerSideEncryptionByDefault:
            SSEAlgorithm: AES256
    AccessControl: Private
    VersioningConfiguration:
      Status: Enabled
    PublicAccessBlockConfiguration:
      BlockPublicAcls: true
      IgnorePublicAcls: true
      BlockPublicPolicy: true
      RestrictPublicBuckets: true
LogBucket:
  Type: AWS::S3::Bucket
  Properties:
    BucketName: !Sub 'logs.${DomainName}'
    BucketEncryption:
      ServerSideEncryptionConfiguration:
        - ServerSideEncryptionByDefault:
            SSEAlgorithm: AES256
    AccessControl: Private
    VersioningConfiguration:
      Status: Enabled
    PublicAccessBlockConfiguration:
      BlockPublicAcls: true
      IgnorePublicAcls: true
      BlockPublicPolicy: true
      RestrictPublicBuckets: true

These buckets are configured with some security features: server-side encryption is enabled, and public access is blocked. We’re able to block public access to the website bucket since it will sit behind a CloudFront distribution. Next, let’s create a certificate for the CloudFront distribution:

WebCertificate:
  Type: AWS::CertificateManager::Certificate
  Properties:
    DomainName: !Ref DomainName
    SubjectAlternativeNames:
      - !Sub 'www.${DomainName}'
    ValidationMethod: DNS
    DomainValidationOptions:
      - DomainName: !Ref DomainName
        HostedZoneId: !Ref HostedZoneId
      - DomainName: !Sub 'www.${DomainName}'
        HostedZoneId: !Ref HostedZoneId

For domains hosted in Route 53, this approach will work fine: CloudFormation will automatically perform domain validation for the certificate. For externally hosted domains, manual verification is required. In that case, it would probably be best to leave the certificate outside of the SAM template. We’re now ready to create the CloudFront distribution, along with a CloudFront origin access identity (OAI) for the distribution to access the S3 website bucket:

CFOriginAccessIdentity:
  Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
  Properties:
    CloudFrontOriginAccessIdentityConfig:
      Comment: !Sub '${DomainName} CloudFront OAI'
CFDistribution:
  Type: AWS::CloudFront::Distribution
  DependsOn:
    - WebsiteBucket
    - LogBucket
  Properties:
    DistributionConfig:
      Enabled: 'true'
      Comment: !Sub 'Distribution for ${DomainName}'
      HttpVersion: http2
      PriceClass: !Ref CFDistributionPriceClass
      DefaultRootObject: index.html
      Aliases:
        - !Ref DomainName
        - !Sub 'www.${DomainName}'
      Origins:
        - Id: !Sub 'S3-${DomainName}'
          DomainName: !Sub '${DomainName}.s3.amazonaws.com'
          S3OriginConfig:
            OriginAccessIdentity: !Sub 'origin-access-identity/cloudfront/${CFOriginAccessIdentity}'
      DefaultCacheBehavior:
        TargetOriginId: !Sub 'S3-${DomainName}'
        Compress: true
        ForwardedValues:
          QueryString: false
          Headers:
            - Origin
          Cookies:
            Forward: none
        ViewerProtocolPolicy: allow-all
      CustomErrorResponses:
        - ErrorCode: '403'
          ResponsePagePath: /404.html
          ResponseCode: '404'
          ErrorCachingMinTTL: '30'
      ViewerCertificate:
        AcmCertificateArn: !Ref WebCertificate
        SslSupportMethod: sni-only
        MinimumProtocolVersion: TLSv1.2_2019
      Logging:
        IncludeCookies: false
        Bucket: !Sub 'logs.${DomainName}.s3.amazonaws.com'
        Prefix: cf-logs

The CloudFront distribution references all of the other resources we’ve created so far. Since we’re not explicitly referencing the outputs of the WebsiteBucket and LogBucket resources, we need to add a DependsOn statement to ensure that they’re created before the CloudFront distribution comes up. With the CloudFront distribution and OAI created, we’ll need an S3 bucket policy to allow the OAI to access the website bucket:

WebsiteBucketPolicy:
  Type: AWS::S3::BucketPolicy
  Properties:
    Bucket: !Ref WebsiteBucket
    PolicyDocument:
      Statement:
        - Effect: Allow
          Action: s3:GetObject
          Principal:
            CanonicalUser: !GetAtt CFOriginAccessIdentity.S3CanonicalUserId
          Resource: !Sub 'arn:aws:s3:::${WebsiteBucket}/*'

Finally, let’s create the DNS records for the website:

DNSRecordSet:
  Type: AWS::Route53::RecordSetGroup
  Properties:
    Comment: !Sub 'DNS records for ${DomainName}'
    HostedZoneId: !Ref HostedZoneId
    RecordSets:
      - Name: !Ref DomainName
        Type: A
        AliasTarget:
          DNSName: !GetAtt CFDistribution.DomainName
          EvaluateTargetHealth: false
          HostedZoneId: Z2FDTNDATAQYW2
      - Name: !Sub 'www.${DomainName}'
        Type: A
        AliasTarget:
          DNSName: !GetAtt CFDistribution.DomainName
          EvaluateTargetHealth: false
          HostedZoneId: Z2FDTNDATAQYW2

We have two records here: one for the root domain, and one for the www subdomain. We can also create other records here, such as AAAA records for IPv6. The HostedZoneId parameter for the AliasTarget resource has to be set to Z2FDTNDATAQYW2 for CloudFront distributions (see Amazon’s documentation for this).

In part 2 of this series, we’ll deploy this template and write some code to implement URL redirection.