This is what we’re going to create:

                +------------------------------------------------+
                | AWS Account                                    |
                |                                                |
+----------+    |    +--------------+        +---------------+   |
|          |    |    |              |        |               |   |
|  Client  +----+--->|  CloudFront  +------->|   S3 Bucket   |   |
|          |    |    |              |        |               |   |
+----------+    |    +--------------+        +---------------+   |
                |                                                |
                |                                                |
                +------------------------------------------------+

The architecture keeps our S3 bucket (where our static content lives) private. It can only be accessed via CloudFront. This is great for two main reasons:

  • Accessing content from a bucket is slower than from the CloudFront edge cache.
  • Accessing content from the cache is much cheaper than reading from S3 in large amounts.

I automated the creation of the whole stack with a CloudFormation template:

Resources:
  # An S3 bucket that holds our site's static content. This bucket is totally private.
  Bucket:
    Type: AWS::S3::Bucket
    Properties:
      AccessControl: Private
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerEnforced
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
  # Attach a bucket policy that allows access to the content in the bucket only for the CloudFront
  # distribution that is created later. This prevents a user from directly accessing our bucket.
  BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref Bucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowCloudFrontRead
            Effect: Allow
            Principal:
              Service: cloudfront.amazonaws.com
            Action: s3:GetObject
            Resource: !Sub ${Bucket.Arn}/*
            Condition:
              StringEquals:
                AWS:SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution.Id}
  # This OriginAccessControl is what the CloudFront distribution will use to sign requests to prove
  # to the bucket policy that it has access.
  OriginAccessControl:
    Type: AWS::CloudFront::OriginAccessControl
    Properties:
      OriginAccessControlConfig:
        Name: !GetAtt Bucket.RegionalDomainName
        OriginAccessControlOriginType: s3
        SigningBehavior: always
        SigningProtocol: sigv4
  # Cache everything for 60 seconds, unless the object has a custom cache header.
  CachePolicy:
    Type: AWS::CloudFront::CachePolicy
    Properties:
      CachePolicyConfig:
        DefaultTTL: 60
        MaxTTL: 300
        MinTTL: 0
        Name: DefaultCachePolicy
        # Don't forward anything from the user - we only cache static content.
        ParametersInCacheKeyAndForwardedToOrigin:
          CookiesConfig:
            CookieBehavior: none
          EnableAcceptEncodingGzip: true
          HeadersConfig:
            HeaderBehavior: none
          QueryStringsConfig:
            QueryStringBehavior: none
  # Create a CloudFront distribution that targets the S3 bucket.
  # / corresponds to an index.html in the bucket.
  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          CachePolicyId: !Ref CachePolicy
          TargetOriginId: !GetAtt Bucket.RegionalDomainName
          ViewerProtocolPolicy: redirect-to-https
        Enabled: true
        HttpVersion: http2and3
        Origins:
          - DomainName: !GetAtt Bucket.RegionalDomainName
            Id: !GetAtt Bucket.RegionalDomainName
            OriginAccessControlId: !Ref OriginAccessControl
            # The schema mandates we specify this, but we use an OAC rather 
            # than an OAI, so don't specify one.
            S3OriginConfig:
              OriginAccessIdentity: ""
Outputs:
  DomainName:
    Value: !GetAtt Distribution.DomainName

Assuming you save this as template.yml, you can create a website stack with this command:

aws cloudformation create-stack --stack-name website --template-body file://template.yml

It takes a few minutes for the distribution to be deployed into all edge locations. While you wait, upload your static content into the created bucket, which should be named similarly to website-bucket-bu1bdapqunak. Here’s an example index.html you could use:

<!DOCTYPE html>
<html>
    <head>
        <title>CloudFront Site</title>
    </head>
    <body>
        <h1>Hello, World!</h1>
    </body>
</html>

When the stack has completed, it outputs the domain name of the distribution. You can navigate to this to view your content!